@clawhub-anderskev-60f39d7981
Comprehensive Go backend code review with optional parallel agents
---
description: Comprehensive Go backend code review with optional parallel agents
name: review-go
disable-model-invocation: true
---
# Go Backend Code Review
## Arguments
- `--parallel`: Spawn specialized subagents per technology area
- Path: Target directory (default: current working directory)
## Step 1: Identify Changed Files
```bash
git diff --name-only $(git merge-base HEAD main)..HEAD | grep -E '\.go$'
```
**Pass condition:** If this prints nothing, state **No Go files in this diff** in the summary and **skip Steps 2–6**; do not invent findings for out-of-scope files.
## Step 2: Detect Technologies
```bash
# Detect BubbleTea TUI
grep -r "charmbracelet/bubbletea\|tea\.Model\|tea\.Cmd" --include="*.go" -l | head -3
# Detect Wish SSH
grep -r "charmbracelet/wish\|ssh\.Session\|wish\.Middleware" --include="*.go" -l | head -3
# Detect Prometheus
grep -r "prometheus/client_golang\|promauto\|prometheus\.Counter" --include="*.go" -l | head -3
# Detect ZeroLog
grep -r "rs/zerolog\|zerolog\.Logger" --include="*.go" -l | head -3
# Check for test files
git diff --name-only $(git merge-base HEAD main)..HEAD | grep -E '_test\.go$'
```
## Step 3: Load Verification Protocol
Load `beagle-go:review-verification-protocol` skill and keep its checklist in mind throughout the review.
## Step 4: Load Skills
Use the `Skill` tool to load each applicable skill (e.g., `Skill(skill: "beagle-go:go-code-review")`).
**Always load:**
- `beagle-go:go-code-review`
**Conditionally load based on detection:**
| Condition | Skill |
|-----------|-------|
| Test files changed | `beagle-go:go-testing-code-review` |
| BubbleTea detected | `beagle-go:bubbletea-code-review` |
| Wish SSH detected | `beagle-go:wish-ssh-code-review` |
| Prometheus detected | `beagle-go:prometheus-go-code-review` |
**Pass before Step 5:** You have loaded `beagle-go:go-code-review` (and Step 3 verification protocol). Load a **conditional** skill only when its row applies: `_test.go` in Step 1 diff → testing skill; BubbleTea/Wish/Prometheus skill only if the matching Step 2 `grep` returned at least one path (if `grep` returned nothing, do **not** load that skill).
## Step 5: Review
**Sequential (default):**
1. Load applicable skills
2. Review Go quality issues first (error handling, concurrency, interfaces)
3. Review detected technology areas
4. Consolidate findings
**Parallel (--parallel flag):**
1. Detect all technologies upfront
2. Spawn one subagent per technology area with `Task` tool
3. Each agent loads its skill and reviews its domain
4. Wait for all agents
5. Consolidate findings
## Step 6: Verify Findings
Before reporting any issue:
1. Re-read the actual code (not just diff context)
2. For "unused" claims - did you search all references?
3. For "missing" claims - did you check framework/parent handling?
4. For syntax issues - did you verify against current version docs?
5. Remove any findings that are style preferences, not actual issues
**Hard gates before listing any Critical or Major issue** (Informational may be lighter):
1. **Read-depth:** You opened the file on disk and read at least the enclosing function or block (diff-only or excerpt-only reading is not enough).
2. **Unused / dead code:** You ran a reference search (`rg`/IDE) and noted the result in the finding (e.g. no references outside tests), or you are not claiming unused symbols.
3. **“Missing” behavior:** You checked callers, framework wiring, or docs for the claimed gap, or you downgraded/dropped the item.
## Step 7: Review Convergence
### Single-Pass Completeness
You MUST report ALL issues across ALL categories (style, logic, types, tests, security, performance) in a single review pass. Do not hold back issues for later rounds.
Before submitting findings, ask yourself:
- "If all my recommended fixes are applied, will I find NEW issues in the fixed code?"
- "Am I requesting new code (tests, types, modules) that will itself need review?"
If yes to either: include those anticipated downstream issues NOW, in this review, so the author can address everything at once.
### Scope Rules
- Review ONLY the code in the diff and directly related existing code
- Do NOT request new features, test infrastructure, or architectural changes that didn't exist before the diff
- If test coverage is missing, flag it as ONE Minor issue ("Missing test coverage for X, Y, Z") — do NOT specify implementation details like mock libraries, behaviour extraction, or dependency injection patterns that would introduce substantial new code
- Typespecs, documentation, and naming issues are Minor unless they affect public API contracts
- Do NOT request adding new dependencies (e.g. Mox, testing libraries, linter plugins)
### Fix Complexity Budget
Fixes to existing code should be flagged at their real severity regardless of size.
However, requests for **net-new code that didn't exist before the diff** must be classified as Informational:
- Adding a new dependency (e.g. Mox, a linter plugin)
- Creating entirely new modules, files, or test suites
- Extracting new behaviours, protocols, or abstractions
These are improvement suggestions for the author to consider in future work, not review blockers.
### Iteration Policy
If this is a re-review after fixes were applied:
- ONLY verify that previously flagged issues were addressed correctly
- Do NOT introduce new findings unrelated to the previous review's issues
- Accept Minor/Nice-to-Have issues that weren't fixed — do not re-flag them
- The goal of re-review is VERIFICATION, not discovery
## Output Format
```markdown
## Review Summary
[1-2 sentence overview of findings]
## Issues
### Critical (Blocking)
1. [FILE:LINE] ISSUE_TITLE
- Issue: Description of what's wrong
- Why: Why this matters (bug, race condition, resource leak, security)
- Fix: Specific recommended fix
### Major (Should Fix)
2. [FILE:LINE] ISSUE_TITLE
- Issue: ...
- Why: ...
- Fix: ...
### Minor (Nice to Have)
N. [FILE:LINE] ISSUE_TITLE
- Issue: ...
- Why: ...
- Fix: ...
### Informational (For Awareness)
N. [FILE:LINE] SUGGESTION_TITLE
- Suggestion: ...
- Rationale: ...
## Good Patterns
- [FILE:LINE] Pattern description (preserve this)
## Verdict
Ready: Yes | No | With fixes 1-N (Critical/Major only; Minor items are acceptable)
Rationale: [1-2 sentences]
```
## Post-Fix Verification
After fixes are applied, run:
```bash
go build ./...
go vet ./...
golangci-lint run
go test -v -race ./...
```
All checks must pass before approval.
## Rules
- Load skills BEFORE reviewing (not after)
- Number every issue sequentially (1, 2, 3...)
- Include FILE:LINE for each issue
- Separate Issue/Why/Fix clearly
- Categorize by actual severity
- Check for race conditions with `-race` flag
- Run verification after fixes
- Report ALL issues in a single pass — do not hold back findings for later iterations
- Re-reviews verify previous fixes ONLY — no new discovery
- Requests for net-new code (new modules, dependencies, test suites) are Informational, not blocking
- The Verdict ignores Minor and Informational items — only Critical and Major block approval
Comprehensive Elixir/Phoenix code review with optional parallel agents
---
description: Comprehensive Elixir/Phoenix code review with optional parallel agents
name: review-elixir
disable-model-invocation: true
---
# Elixir Code Review
## Arguments
- `--parallel`: Spawn specialized subagents per technology area
- Path: Target directory (default: current working directory)
## Hard gates
Complete in order before writing **Issues** in the output (empty scope is allowed; fabricated findings are not).
1. **Scope gate:** You have an explicit list of `.ex`/`.exs`/`.heex` paths under review (from Step 1 or user path). **Pass:** List printed or "No Elixir files in scope" — then stop with no Issues.
2. **Linter gate (style):** Step 2 commands ran for this Mix project; skipped tools are noted in one line (e.g. no `.credo.exs`). **Pass:** You do not report a style issue that already passes the project's formatter/linter for that line.
3. **Protocol gate:** `beagle-elixir:review-verification-protocol` is loaded before Step 6. **Pass:** At least one reported finding was checked against that checklist (state which item in the Review Summary or first Critical/Major note).
4. **Evidence gate (Critical/Major):** For each Critical or Major item, you re-read the file at `FILE:LINE` (full surrounding context, not only the diff hunk). **Pass:** The Issue description matches observable code at that location.
## Step 1: Identify Changed Files
```bash
git diff --name-only $(git merge-base HEAD main)..HEAD | grep -E '\.ex$|\.exs$|\.heex$'
```
## Step 2: Verify Linter/Formatter Status
**CRITICAL**: Run project linters BEFORE flagging any style issues.
```bash
# Check formatting
mix format --check-formatted
# Check Credo if present
if [ -f ".credo.exs" ] || grep -q ":credo" mix.exs 2>/dev/null; then
mix credo --strict
fi
# Check Dialyzer if configured
if grep -q ":dialyxir" mix.exs 2>/dev/null; then
mix dialyzer --format short
fi
```
**Rules:**
- If a linter passes for a specific rule, DO NOT flag that issue manually
- Linter configuration is authoritative for style rules
- Only flag issues that linters cannot detect (semantic issues, architectural problems)
## Step 3: Detect Technologies
```bash
# Detect Phoenix
grep -r "use Phoenix\|Phoenix.Router\|Phoenix.Controller" --include="*.ex" -l | head -3
# Detect LiveView
grep -r "use Phoenix.LiveView\|Phoenix.LiveComponent\|~H" --include="*.ex" -l | head -3
# Detect Oban
grep -r "use Oban.Worker\|Oban.insert" --include="*.ex" -l | head -3
# Check for test files
git diff --name-only $(git merge-base HEAD main)..HEAD | grep -E '_test\.exs$'
```
## Step 4: Load Verification Protocol
Load `beagle-elixir:review-verification-protocol` skill and keep its checklist in mind throughout the review.
## Step 5: Load Skills
Use the `Skill` tool to load each applicable skill.
**Always load:**
- `beagle-elixir:elixir-code-review`
**Conditionally load based on detection:**
| Condition | Skill |
|-----------|-------|
| Phoenix detected | `beagle-elixir:phoenix-code-review` |
| LiveView detected | `beagle-elixir:liveview-code-review` |
| Performance focus requested | `beagle-elixir:elixir-performance-review` |
| Security focus requested | `beagle-elixir:elixir-security-review` |
| Test files changed | `beagle-elixir:exunit-code-review` |
## Step 6: Review
**Sequential (default):**
1. Load applicable skills
2. Review Elixir quality issues first
3. Review Phoenix patterns (if detected)
4. Review LiveView patterns (if detected)
5. Review detected technology areas
6. Consolidate findings
**Parallel (--parallel flag):**
1. Detect all technologies upfront
2. Spawn one subagent per technology area with `Task` tool
3. Each agent loads its skill and reviews its domain
4. Wait for all agents
5. Consolidate findings
### Before Flagging Issues
1. **Check CLAUDE.md** for documented intentional patterns
2. **Check code comments** around the flagged area for "intentional", "optimization", or "NOTE:"
3. **Trace the code path** before claiming missing coverage
4. **Consider framework idioms** - what looks wrong generically may be correct for Elixir/Phoenix
## Step 7: Verify Findings
Satisfy **Hard gates** items 2–4 before finalizing Issues. Before reporting any issue:
1. Re-read the actual code (not just diff context)
2. For "unused" claims - did you search all references?
3. For "missing" claims - did you check framework/parent handling?
4. For syntax issues - did you verify against current version docs?
5. Remove any findings that are style preferences, not actual issues
## Step 8: Review Convergence
### Single-Pass Completeness
You MUST report ALL issues across ALL categories (style, logic, types, tests, security, performance) in a single review pass. Do not hold back issues for later rounds.
Before submitting findings, ask yourself:
- "If all my recommended fixes are applied, will I find NEW issues in the fixed code?"
- "Am I requesting new code (tests, types, modules) that will itself need review?"
If yes to either: include those anticipated downstream issues NOW, in this review, so the author can address everything at once.
### Scope Rules
- Review ONLY the code in the diff and directly related existing code
- Do NOT request new features, test infrastructure, or architectural changes that didn't exist before the diff
- If test coverage is missing, flag it as ONE Minor issue ("Missing test coverage for X, Y, Z") — do NOT specify implementation details like mock libraries, behaviour extraction, or dependency injection patterns that would introduce substantial new code
- Typespecs, documentation, and naming issues are Minor unless they affect public API contracts
- Do NOT request adding new dependencies (e.g. Mox, testing libraries, linter plugins)
### Fix Complexity Budget
Fixes to existing code should be flagged at their real severity regardless of size.
However, requests for **net-new code that didn't exist before the diff** must be classified as Informational:
- Adding a new dependency (e.g. Mox, a linter plugin)
- Creating entirely new modules, files, or test suites
- Extracting new behaviours, protocols, or abstractions
These are improvement suggestions for the author to consider in future work, not review blockers.
### Iteration Policy
If this is a re-review after fixes were applied:
- ONLY verify that previously flagged issues were addressed correctly
- Do NOT introduce new findings unrelated to the previous review's issues
- Accept Minor/Nice-to-Have issues that weren't fixed — do not re-flag them
- The goal of re-review is VERIFICATION, not discovery
## Output Format
```markdown
## Review Summary
[1-2 sentence overview of findings]
## Issues
### Critical (Blocking)
1. [FILE:LINE] ISSUE_TITLE
- Issue: Description of what's wrong
- Why: Why this matters (bug, type safety, security)
- Fix: Specific recommended fix
### Major (Should Fix)
2. [FILE:LINE] ISSUE_TITLE
- Issue: ...
- Why: ...
- Fix: ...
### Minor (Nice to Have)
N. [FILE:LINE] ISSUE_TITLE
- Issue: ...
- Why: ...
- Fix: ...
### Informational (For Awareness)
N. [FILE:LINE] SUGGESTION_TITLE
- Suggestion: ...
- Rationale: ...
## Good Patterns
- [FILE:LINE] Pattern description (preserve this)
## Verdict
Ready: Yes | No | With fixes 1-N (Critical/Major only; Minor items are acceptable)
Rationale: [1-2 sentences]
```
## Post-Fix Verification
After fixes are applied, run the same checks as Step 2, then tests:
```bash
mix format --check-formatted
if [ -f ".credo.exs" ] || grep -q ":credo" mix.exs 2>/dev/null; then
mix credo --strict
fi
if grep -q ":dialyxir" mix.exs 2>/dev/null; then
mix dialyzer --format short
fi
mix test
```
All invoked checks must pass before approval.
## Rules
- Load skills BEFORE reviewing (not after)
- Number every issue sequentially (1, 2, 3...)
- Include FILE:LINE for each issue
- Separate Issue/Why/Fix clearly
- Categorize by actual severity
- Run verification after fixes
- Report ALL issues in a single pass — do not hold back findings for later iterations
- Re-reviews verify previous fixes ONLY — no new discovery
- Requests for net-new code (new modules, dependencies, test suites) are Informational, not blocking
- The Verdict ignores Minor and Informational items — only Critical and Major block approval
How-To guide patterns for documentation - task-oriented guides for users with specific goals
---
name: howto-docs
description: How-To guide patterns for documentation - task-oriented guides for users with specific goals
user-invocable: false
autoContext:
whenUserAsks:
- how to guide
- how-to guide
- howto guide
- task guide
- procedural guide
- step-by-step guide
- how to documentation
dependencies:
- docs-style
---
# How-To Documentation Skill
This skill provides patterns for writing effective How-To guides in documentation. How-To guides are task-oriented content for users who have a specific goal in mind.
## Purpose & Audience
**Target readers:**
- Users with a specific goal they want to accomplish
- Assumes some familiarity with the product (not complete beginners)
- Looking for practical, actionable steps
- Want to get things done, not learn concepts
**How-To guides are NOT:**
- Tutorials (which teach through exploration)
- Explanations (which provide understanding)
- Reference docs (which describe the system)
## How-To Guide Template
Use this structure for all how-to guides:
```markdown
---
title: "How to [achieve specific goal]"
description: "Learn how to [goal] using [product/feature]"
---
# How to [Goal]
Brief intro (1-2 sentences): what you'll accomplish and why it's useful.
## Prerequisites
- [What user needs before starting]
- [Required access, tools, or setup]
- [Any prior knowledge assumed]
## Steps
### 1. [Action verb] the [thing]
[Clear instruction with expected outcome]
<Note>
[Optional tip or important context]
</Note>
### 2. [Next action]
[Continue with clear, single-action steps]
```bash
# Example command or code if needed
```
### 3. [Continue numbering]
[Each step should be one discrete action]
## Verify it worked
[How to confirm success - what should user see/experience?]
## Troubleshooting
<AccordionGroup>
<Accordion title="[Common issue 1]">
[Solution or workaround]
</Accordion>
<Accordion title="[Common issue 2]">
[Solution or workaround]
</Accordion>
</AccordionGroup>
## Next steps
- [Related how-to guide 1]
- [Related how-to guide 2]
- [Deeper dive reference doc]
```
## Writing Principles
### Title Conventions
- **Always start with "How to"** - makes the goal immediately clear
- Use active verbs: "How to configure...", "How to deploy...", "How to migrate..."
- Be specific: "How to add SSO authentication" not "How to set up auth"
### Step Structure
1. **One action per step** - if you write "and", consider splitting
2. **Start with action verbs**: Click, Navigate, Enter, Select, Run, Create
3. **Show expected outcomes** after key steps:
```markdown
### 3. Save the configuration
Click **Save**. You should see a success message: "Configuration updated."
```
### Minimize Context
- Don't explain why things work - just show how to do them
- Link to explanations for users who want deeper understanding
- Keep each step focused on the immediate action
### User Perspective
Write from the user's perspective, not the product's:
| Avoid (product-centric) | Prefer (user-centric) |
|------------------------|----------------------|
| "The API accepts..." | "Send a request to..." |
| "The system will..." | "You'll see..." |
| "This feature allows..." | "You can now..." |
### Prerequisites Section
Be explicit about what's needed:
```markdown
## Prerequisites
- An active account with admin permissions
- API key generated from Settings > API
- Node.js v18 or later installed
- Completed the [initial setup guide](/getting-started)
```
## Components for How-To Guides
### Steps Component
For numbered procedures, use a Steps component:
```markdown
<Steps>
<Step title="Create a new project">
Navigate to the dashboard and click **New Project**.
</Step>
<Step title="Configure settings">
Enter your project name and select a region.
</Step>
<Step title="Deploy">
Click **Deploy** to launch your project.
</Step>
</Steps>
```
### Code Groups for Multiple Options
When showing different approaches:
```markdown
<CodeGroup>
```bash npm
npm install @company/sdk
```
```bash yarn
yarn add @company/sdk
```
```bash pnpm
pnpm add @company/sdk
```
</CodeGroup>
```
### Callouts for Important Information
```markdown
<Warning>
This action cannot be undone. Make sure to backup your data first.
</Warning>
<Note>
This step may take 2-3 minutes to complete.
</Note>
<Tip>
You can also use keyboard shortcut Cmd+K for faster navigation.
</Tip>
```
### Expandable Sections
For optional details that shouldn't interrupt flow:
```markdown
<Expandable title="Advanced options">
If you need custom configuration, you can also set:
- `timeout`: Request timeout in milliseconds
- `retries`: Number of retry attempts
</Expandable>
```
## Example How-To Guide
```markdown
---
title: "How to set up webhook notifications"
description: "Learn how to configure webhooks to receive real-time event notifications"
---
# How to Set Up Webhook Notifications
Configure webhooks to receive instant notifications when events occur in your account. This enables real-time integrations with your existing tools.
## Prerequisites
- Admin access to your account
- A publicly accessible HTTPS endpoint to receive webhooks
- Completed the [authentication setup](/getting-started/auth)
## Steps
<Steps>
<Step title="Navigate to webhook settings">
Go to **Settings** > **Integrations** > **Webhooks**.
</Step>
<Step title="Add a new webhook endpoint">
Click **Add Endpoint** and enter your webhook URL:
```
https://your-domain.com/webhooks/receiver
```
<Note>
Your endpoint must use HTTPS and be publicly accessible.
</Note>
</Step>
<Step title="Select events to subscribe">
Choose which events should trigger notifications:
- `user.created` - New user sign up
- `payment.completed` - Successful payment
- `subscription.cancelled` - Subscription ended
Select at least one event to continue.
</Step>
<Step title="Save and get your signing secret">
Click **Create Webhook**. Copy the signing secret shown - you'll need this to verify webhook authenticity.
<Warning>
Store the signing secret securely. It won't be shown again.
</Warning>
</Step>
</Steps>
## Verify it worked
Send a test event by clicking **Send Test** next to your webhook. You should receive a POST request at your endpoint with this structure:
```json
{
"event": "test.webhook",
"timestamp": "2024-01-15T10:30:00Z",
"data": {}
}
```
Check your endpoint logs to confirm receipt.
## Troubleshooting
<AccordionGroup>
<Accordion title="Webhook not receiving events">
- Verify your endpoint is publicly accessible
- Check that your SSL certificate is valid
- Ensure your server responds with 2xx status within 30 seconds
</Accordion>
<Accordion title="Signature verification failing">
- Confirm you're using the correct signing secret
- Check that you're reading the raw request body (not parsed JSON)
- See our [signature verification guide](/reference/webhook-signatures)
</Accordion>
</AccordionGroup>
## Next steps
- [How to verify webhook signatures](/how-to/verify-webhook-signatures)
- [Webhook event reference](/reference/webhook-events)
- [How to handle webhook retries](/how-to/webhook-retry-handling)
```
## Hard gates (before publishing)
Complete in order. Do not treat the doc as ready until each gate passes.
1. **Goal lock** — Title starts with `How to` and names a specific outcome (verb + object). **Pass:** a reader can state what they will have done after following the guide, in one sentence, without reading the steps.
2. **Prerequisites closed** — Everything required before step 1 is listed (access, tools, versions, prior guides). **Pass:** you cannot name a blocker that belongs in Prerequisites but is missing from the list.
3. **Steps are atomic** — Each numbered step is one primary action; split if you would join with “and then” for unrelated actions. **Pass:** each step has a clear next action; risky steps (save, deploy, delete) state what the user should see after.
4. **Success is observable** — The “Verify it worked” section names concrete signals (UI text, exit code, HTTP status, file path, log line). **Pass:** the reader can confirm success without interpreting vague “it works.”
5. **Checklist complete** — Run the checklist below; every item is honestly yes or the guide is not ready to ship. **Pass:** all boxes checked.
## Checklist for How-To Guides
Before publishing, verify:
- [ ] Title starts with "How to" and describes a specific goal
- [ ] Prerequisites section lists all requirements
- [ ] Each step is a single, clear action
- [ ] Action verbs start each step (Click, Enter, Select, Run)
- [ ] Expected outcomes shown after key steps
- [ ] Verification section explains how to confirm success
- [ ] Troubleshooting covers common issues
- [ ] Next steps link to related content
- [ ] No unnecessary explanations - links to concepts instead
- [ ] Written from user perspective, not product perspective
## When to Use How-To vs Other Doc Types
| User's mindset | Doc type | Example |
|---------------|----------|---------|
| "I want to learn" | Tutorial | "Getting started with our API" |
| "I want to do X" | How-To | "How to configure SSO" |
| "I want to understand" | Explanation | "How our caching works" |
| "I need to look up Y" | Reference | "API endpoint reference" |
## Related Skills
- **docs-style**: Core writing conventions and components
- **tutorial-docs**: Tutorial patterns for learning-oriented content
- **reference-docs**: Reference documentation patterns
- **explanation-docs**: Conceptual documentation patterns
Verify documentation coverage and generate missing docs interactively
---
name: ensure-docs
description: Verify documentation coverage and generate missing docs interactively
disable-model-invocation: true
---
# Ensure Documentation Coverage
Verify documentation coverage across a codebase, report gaps, and generate missing docs with parallel language-specific agents.
## Workflow
Complete steps in order. Do not advance until each step’s **Pass** is satisfied.
1. **Language detection** — Follow Phase 1 (language detection) in [`references/workflow.md`](references/workflow.md).
- **Pass:** For each language you will verify, you have evidence of at least one matching source file (counts or command output); if none qualify, stop with a short “no applicable languages” message and do not spawn verifiers.
2. **Load standards** — Read the sections for your detected languages (language standards, verifier prompts, consolidation format) in the same reference file.
- **Pass:** You can state which standard applies per language (e.g. Google docstrings, JSDoc, GoDoc) before spawning agents.
3. **Parallel verification** — Spawn one verifier per qualifying language using the agent prompts and JSON output shape in the reference (Phase 2).
- **Pass:** Each completed agent returns parseable JSON including `language`, `files_scanned`, and `findings` (array, possibly empty).
4. **Consolidated report** — Merge results per Phase 3 (summary table, severity grouping, detailed findings if requested).
- **Pass:** The user sees the merged report (inline or written to an agreed path) before you claim the audit is done or propose fixes.
5. **Generation** — Only if `--report-only` is not set: offer choices per Phase 4; apply doc edits only after an explicit user choice to generate.
- **Pass:** No documentation edits for gaps until the user selects an option that includes generation; if they decline or choose report-only behavior, end after the report.
6. **Post-edit verification** — After any generation, run or offer the linter commands in Phase 5 of the reference for languages you changed, when those tools exist in the repo.
- **Pass:** Linter run completed with output captured, or `N/A` with a one-line reason (e.g. tool not configured); remaining issues are listed or cleared.
## Notes
- Use `--report-only` to skip generation.
- Avoid test files unless they are test helpers.
- Keep report output aligned with the language-specific standards in the reference file.
FILE:references/workflow.md
---
description: Verify documentation coverage and generate missing docs interactively
---
# Ensure Documentation Coverage
Verify code documentation coverage across a codebase, report gaps, and interactively generate missing documentation using parallel language-specific agents.
## Arguments
- `Path`: Target directory (default: current working directory)
- `--report-only`: Skip interactive generation, just output findings
## Workflow Overview
1. **Detect** languages present in the codebase
2. **Spawn** parallel verification agents per language
3. **Merge** and present consolidated findings
4. **Offer** interactive generation choices
5. **Generate** missing docs if requested
6. **Verify** with language linters
## Phase 1: Language Detection
Detect which languages are present in the codebase:
```bash
# Python detection
PYTHON_FILES=$(find . -type f -name "*.py" ! -path "./.*" ! -path "./venv/*" ! -path "./.venv/*" | head -100)
PYTHON_COUNT=$(echo "$PYTHON_FILES" | grep -c . || echo 0)
# TypeScript/JavaScript detection
TS_FILES=$(find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) ! -path "./node_modules/*" ! -path "./.*" | head -100)
TS_COUNT=$(echo "$TS_FILES" | grep -c . || echo 0)
# Go detection
GO_FILES=$(find . -type f -name "*.go" ! -path "./vendor/*" ! -path "./.*" | head -100)
GO_COUNT=$(echo "$GO_FILES" | grep -c . || echo 0)
```
### Framework Detection
```bash
# FastAPI detection (affects OpenAPI handling)
grep -r "from fastapi\|FastAPI\|@app\.\|@router\." --include="*.py" -l 2>/dev/null | head -1
# Check existing OpenAPI specs
ls -la openapi.yaml swagger.json api.yaml 2>/dev/null
```
### Detection Output
Report detected languages:
| Language | Files Found | Standard |
|----------|-------------|----------|
| Python | $PYTHON_COUNT | Google docstrings |
| TypeScript/JS | $TS_COUNT | JSDoc |
| Go | $GO_COUNT | GoDoc |
Proceed only with languages that have at least 1 file detected.
## Language Standards
### Python (Google Docstrings)
**What to check:**
- All public functions (not starting with `_`)
- All classes
- All modules (top-of-file docstring)
**Required docstring elements:**
- Description (first line, imperative mood)
- Args: Parameter name, type, and description
- Returns: Return type and description
- Raises: Exception types and when raised
**Example of compliant docstring:**
```python
def process_request(data: dict, timeout: int = 30) -> Response:
"""Process an incoming API request.
Args:
data: The request payload as a dictionary.
timeout: Maximum seconds to wait for processing.
Returns:
Response object containing status and result.
Raises:
ValidationError: If data fails schema validation.
TimeoutError: If processing exceeds timeout.
"""
```
**Missing indicators:**
- No docstring at all
- Docstring with only description (missing Args/Returns)
- Mismatched parameters (docstring doesn't match signature)
### TypeScript/JavaScript (JSDoc)
**What to check:**
- All exported functions
- All exported classes
- All exported interfaces and types
- All exported constants with complex types
**Required JSDoc elements:**
- @description or first line description
- @param for each parameter with type and description
- @returns with type and description
- @throws for thrown exceptions
**Example of compliant JSDoc:**
```typescript
/**
* Process an incoming API request.
* @param data - The request payload
* @param timeout - Maximum seconds to wait
* @returns Response containing status and result
* @throws {ValidationError} If data fails validation
*/
export function processRequest(data: RequestData, timeout = 30): Response {
```
**Missing indicators:**
- No JSDoc comment
- JSDoc with only description (missing @param/@returns)
- Mismatched parameters (JSDoc doesn't match signature)
### Go (GoDoc)
**What to check:**
- All exported functions (capitalized names)
- All exported types (structs, interfaces)
- Package comment in one file per package
**Required GoDoc elements:**
- Comment starting with the symbol name
- Description of purpose and behavior
- For complex functions: describe parameters inline
**Example of compliant GoDoc:**
```go
// ProcessRequest handles an incoming API request with the given data
// and timeout. It returns a Response or an error if processing fails.
// The timeout is specified in seconds; use 0 for no timeout.
func ProcessRequest(data map[string]any, timeout int) (*Response, error) {
```
**Missing indicators:**
- No comment above exported symbol
- Comment doesn't start with symbol name
- Comment is too terse (single generic sentence)
## Phase 2: Parallel Verification
Spawn verification agents in parallel for each detected language using the `Task` tool.
### Agent Prompt Template
For each detected language, spawn an agent with:
**Python Agent:**
```
You are a Python documentation verifier. Check all Python files for Google docstring compliance.
STANDARD:
[Embed Python standard from above]
TASK:
1. Find all .py files in the target directory (exclude venv, .venv, __pycache__, tests)
2. For each file, identify public functions (not _prefixed) and classes
3. Check each symbol for docstring presence and completeness
4. Return findings as JSON
OUTPUT FORMAT:
{
"language": "python",
"files_scanned": <count>,
"findings": [
{"file": "path/to/file.py", "line": 15, "symbol": "function_name", "type": "function", "issue": "missing_docstring"},
{"file": "path/to/file.py", "line": 42, "symbol": "ClassName", "type": "class", "issue": "incomplete_docstring", "missing": ["Args", "Returns"]}
]
}
```
**TypeScript Agent:**
```
You are a TypeScript/JavaScript documentation verifier. Check all TS/JS files for JSDoc compliance.
STANDARD:
[Embed TypeScript standard from above]
TASK:
1. Find all .ts, .tsx, .js, .jsx files (exclude node_modules, dist, build)
2. For each file, identify exported functions, classes, interfaces, and types
3. Check each symbol for JSDoc presence and completeness
4. Return findings as JSON
OUTPUT FORMAT:
{
"language": "typescript",
"files_scanned": <count>,
"findings": [
{"file": "src/api.ts", "line": 10, "symbol": "processRequest", "type": "function", "issue": "missing_jsdoc"},
{"file": "src/types.ts", "line": 5, "symbol": "UserData", "type": "interface", "issue": "incomplete_jsdoc", "missing": ["description for userId property"]}
]
}
```
**Go Agent:**
```
You are a Go documentation verifier. Check all Go files for GoDoc compliance.
STANDARD:
[Embed Go standard from above]
TASK:
1. Find all .go files (exclude vendor, _test.go for symbol docs)
2. For each file, identify exported functions and types (Capitalized names)
3. Check each symbol for comment presence and correct format
4. Return findings as JSON
OUTPUT FORMAT:
{
"language": "go",
"files_scanned": <count>,
"findings": [
{"file": "pkg/api/handler.go", "line": 25, "symbol": "ProcessRequest", "type": "function", "issue": "missing_comment"},
{"file": "pkg/models/user.go", "line": 8, "symbol": "User", "type": "struct", "issue": "wrong_format", "detail": "Comment doesn't start with 'User'"}
]
}
```
### Spawning Agents
Use the `Task` tool to spawn agents in parallel:
1. For each detected language with files > 0
2. Spawn agent with subagent_type="general-purpose"
3. Include the language-specific prompt and standard
4. Set run_in_background=false to wait for results
5. Collect JSON output from each agent
## Phase 3: Consolidate Results
After all agents complete, merge their findings.
### Categorize by Severity
Group findings into:
| Severity | Issue Types | Priority |
|----------|-------------|----------|
| **Missing** | `missing_docstring`, `missing_jsdoc`, `missing_comment` | High |
| **Incomplete** | `incomplete_docstring`, `incomplete_jsdoc` (has doc but missing required elements) | Medium |
| **Wrong Format** | `wrong_format` (comment exists but doesn't follow standard) | Low |
### Summary Table
Generate a summary table:
```markdown
## Documentation Audit Results
| Language | Files | Missing | Incomplete | Format Issues |
|------------|-------|---------|------------|---------------|
| Python | 42 | 12 | 5 | 2 |
| TypeScript | 28 | 8 | 3 | 0 |
| Go | 15 | 4 | 1 | 1 |
| **Total** | 85 | 24 | 9 | 3 |
```
### Detailed Report Format
If `--report-only` flag is set OR user requests detailed report:
```markdown
## Detailed Findings
### Python (12 missing, 5 incomplete, 2 format issues)
#### Missing Documentation
1. **[src/api.py:15]** `process_request(data: dict) -> Response`
- Type: function
- Visibility: public
2. **[src/models.py:8]** `class User`
- Type: class
- Visibility: public
#### Incomplete Documentation
3. **[src/utils.py:42]** `validate_input(value, schema)`
- Has: Description
- Missing: Args, Returns, Raises
#### Format Issues
4. **[src/helpers.py:20]** `format_output(data)`
- Issue: Docstring uses reST format instead of Google format
### TypeScript (8 missing, 3 incomplete)
...
### Go (4 missing, 1 incomplete, 1 format issue)
...
```
## Phase 4: Interactive Generation
If `--report-only` is NOT set, offer generation choices.
### User Choice
Use `AskUserQuestion` with these options:
**Question:** "Found {total} documentation gaps. What would you like to do?"
**Options:**
1. "Generate all missing docs" - Generate documentation for all findings
2. "Generate for specific language" - Choose which language(s) to generate for
3. "Show detailed report first" - Display full findings before deciding
4. "Skip generation" - Exit with report only
### Generation Agent Prompts
For each language needing generation, spawn a generation agent:
**Python Generation Agent:**
```
You are a Python documentation generator. Generate Google-format docstrings.
STANDARD:
[Embed Python standard]
SYMBOLS TO DOCUMENT:
[List of file:line:symbol from findings]
FOR EACH SYMBOL:
1. Read the function/class implementation
2. Understand parameters, return values, and exceptions
3. Generate a complete Google-format docstring
4. Apply the edit using the Edit tool
RULES:
- Match existing code style
- Use imperative mood for descriptions
- Include all Args, Returns, Raises
- Don't modify any code logic
```
**TypeScript Generation Agent:**
```
You are a TypeScript documentation generator. Generate JSDoc comments.
STANDARD:
[Embed TypeScript standard]
SYMBOLS TO DOCUMENT:
[List of file:line:symbol from findings]
FOR EACH SYMBOL:
1. Read the function/class/interface implementation
2. Understand parameters, return types, and exceptions
3. Generate a complete JSDoc comment
4. Apply the edit using the Edit tool
RULES:
- Match existing code style
- Include @param, @returns, @throws as needed
- Don't modify any code logic
```
**Go Generation Agent:**
```
You are a Go documentation generator. Generate GoDoc comments.
STANDARD:
[Embed Go standard]
SYMBOLS TO DOCUMENT:
[List of file:line:symbol from findings]
FOR EACH SYMBOL:
1. Read the function/type implementation
2. Understand purpose, parameters, and behavior
3. Generate a comment starting with the symbol name
4. Apply the edit using the Edit tool
RULES:
- Start comment with symbol name
- Be concise but complete
- Don't modify any code logic
```
## Phase 5: Post-Generation Verification
After generating documentation, offer to run language linters to verify.
### Verification Commands
**Python:**
```bash
# Check docstring formatting with ruff (requires convention="google" in pyproject.toml [tool.ruff.lint.pydocstyle])
ruff check . --select=D --output-format=concise
# Alternative: pydocstyle
pydocstyle --convention=google .
```
**TypeScript:**
```bash
# Check JSDoc with eslint (requires eslint-plugin-jsdoc)
npx eslint . --rule 'jsdoc/require-jsdoc: error' --rule 'jsdoc/require-param: error' --rule 'jsdoc/require-returns: error'
```
**Go:**
```bash
# Check with staticcheck (golint is deprecated, use golangci-lint for comprehensive linting)
staticcheck -checks "ST1000,ST1020,ST1021,ST1022" ./...
```
### Verification Flow
1. After generation completes, ask: "Run documentation linters to verify?"
2. If yes, run appropriate linter(s) based on languages that were modified
3. Report any remaining issues
4. Offer to fix linter-reported issues
## Rules
- Always detect languages before spawning agents
- Spawn agents in parallel for efficiency
- Present clear summary before offering generation
- Don't generate docs for test files (except test helpers)
- Respect `--report-only` flag
- Run verification after generation when linters are available
Generate first-draft technical documentation from code analysis
---
name: draft-docs
description: Generate first-draft technical documentation from code analysis
disable-model-invocation: true
---
# Draft Docs
Generate Reference or How-To documentation drafts to `docs/drafts/` for review before publishing.
## Arguments
- **Topic prompt:** Description of what to document (e.g., "Document the WebSocket API")
- **--publish [file]:** Move reviewed draft to final location and update navigation
## Mode 1: Generate Draft
```
/beagle-docs:draft-docs "Document the authentication middleware"
```
### Step 0: Gather Context
Before parsing input, gather project context:
```bash
# Check for existing docs structure
ls -la docs/ 2>/dev/null || echo "No docs/ directory found"
# Identify documentation framework
ls docs/navigation.json docs/mint.json docs/docusaurus.config.js docs/mkdocs.yml 2>/dev/null | head -1
# Check for existing drafts
ls docs/drafts/*.md 2>/dev/null || echo "No existing drafts"
# Get recent code changes for context
git diff --name-only $(git merge-base HEAD main)..HEAD 2>/dev/null | head -20
```
**Capture:**
- Docs structure: `docs/` subdirectories present
- Navigation system: `navigation.json`, `mint.json`, or other config
- Tech stack hints: from file extensions and imports in changed files
- Existing drafts: to avoid duplicates
### Step 1: Parse Input
Extract from the prompt:
1. **Topic:** What to document (e.g., "authentication middleware")
2. **Content type:** Detect from keywords:
| Keywords | Type | Skill |
|----------|------|-------|
| "how to", "guide", "steps", "configure", "set up" | How-To | `howto-docs` |
| "API", "reference", "parameters", "function", "endpoint" | Reference | `reference-docs` |
If ambiguous, ask: "Should this be a Reference doc (technical lookup) or How-To guide (task completion)?"
### Step 2: Load Skills
Always load both:
1. `beagle-docs:docs-style` - Core writing principles
2. Detected type skill:
- `beagle-docs:reference-docs` for Reference
- `beagle-docs:howto-docs` for How-To
### Step 3: Analyze Code
Search the codebase for relevant code:
1. **Symbol search:** Find functions, classes, types matching the topic
2. **File search:** Locate related files by name patterns
3. **Reference search:** Find usage examples
Gather:
- Function/method signatures
- Type definitions
- Existing comments/docstrings
- Usage patterns in tests or examples
### Step 4: Generate Draft
Apply the loaded skills to generate documentation:
**For Reference docs:**
- Follow `reference-docs` template structure
- Document all parameters with types
- Include complete, runnable examples from actual code
- Add Related section linking to connected symbols
**For How-To docs:**
- Follow `howto-docs` template structure
- Start title with "How to"
- List concrete prerequisites
- Break into single-action steps
- Include verification section
### Step 5: Write Draft
1. **Create output path:**
- `docs/drafts/{slug}.md`
- Slug from topic: "WebSocket API" → `websocket-api.md`
2. **Ensure directory exists:**
```bash
mkdir -p docs/drafts
```
3. **Write the draft file** (see **Hard gates** → Write gate: confirm file on disk before the next step)
4. **Report to user:**
```markdown
## Draft Created
**File:** `docs/drafts/{slug}.md`
**Type:** Reference | How-To
**Based on:** [list of analyzed symbols/files]
### Next Steps
1. Review the draft for accuracy
2. Add any missing context or examples
3. When ready, publish with:
```
/beagle-docs:draft-docs --publish docs/drafts/{slug}.md
```
```
### Step 6: End-of-Run Verification
Verify draft generation completed successfully:
```bash
# Confirm draft file exists
ls -la docs/drafts/{slug}.md
# Validate frontmatter (YAML header)
head -10 docs/drafts/{slug}.md | grep -E "^---$|^title:|^description:"
# Check markdown syntax (if markdownlint available)
markdownlint docs/drafts/{slug}.md 2>/dev/null || echo "markdownlint not available"
```
**Verification Checklist:**
- [ ] Draft file created at `docs/drafts/{slug}.md`
- [ ] Frontmatter includes `title` and `description`
- [ ] Content type matches detected type (Reference or How-To)
- [ ] Code examples are complete and runnable
- [ ] All analyzed symbols referenced in draft
If any verification fails, report the specific issue and offer to regenerate.
## Mode 2: Publish Draft
```
/beagle-docs:draft-docs --publish docs/drafts/websocket-api.md
```
### Step 1: Read Draft
Read the draft file and extract:
- Title
- Content type (from frontmatter or structure)
### Step 2: Determine Destination
Ask user which section:
```markdown
Where should this document go?
1. **API Reference** → `docs/api/{slug}.md`
2. **Guides** → `docs/guides/{slug}.md`
3. **How-To** → `docs/how-to/{slug}.md`
4. **Other** → Specify path
```
### Step 3: Move File
```bash
mv docs/drafts/{slug}.md {destination}/{slug}.md
```
### Step 4: Update Navigation
Check for `docs/navigation.json` and update navigation:
1. **Read current navigation.json**
2. **Find appropriate navigation group**
3. **Add new page entry**
4. **Write updated navigation.json**
Example update:
```json
{
"navigation": [
{
"group": "API Reference",
"pages": [
"api/existing-page",
"api/websocket-api"
]
}
]
}
```
### Step 5: Report
```markdown
## Published
**From:** `docs/drafts/{slug}.md`
**To:** `{destination}/{slug}.md`
**Navigation:** Updated `docs/navigation.json`
The document is now live in your docs.
```
### Step 6: End-of-Run Verification
Verify publish completed successfully:
```bash
# Confirm file moved to destination
ls -la {destination}/{slug}.md
# Confirm draft removed
ls docs/drafts/{slug}.md 2>/dev/null && echo "WARNING: Draft still exists" || echo "Draft cleaned up"
# Verify navigation updated
grep -q "{slug}" docs/navigation.json && echo "Navigation includes new page" || echo "WARNING: Navigation may need manual update"
# Check markdown syntax at final location
markdownlint {destination}/{slug}.md 2>/dev/null || echo "markdownlint not available"
```
**Verification Checklist:**
- [ ] Document moved to `{destination}/{slug}.md`
- [ ] Draft removed from `docs/drafts/`
- [ ] Navigation file updated with new page entry
- [ ] No broken links in navigation structure
- [ ] Document accessible at expected URL path
If any verification fails, report the specific issue and offer remediation steps.
## Content Type Detection
### Reference Indicators
- Prompt mentions: API, endpoint, function, method, class, type, parameters, returns
- Target is a specific symbol or set of symbols
- User wants technical specification
### How-To Indicators
- Prompt mentions: how to, guide, steps, configure, set up, integrate
- Target is a task or workflow
- User wants procedural instructions
## Rules
- Always load `docs-style` skill for every draft
- Generate to `docs/drafts/` - never directly to final location
- Include frontmatter with title and description
- Use realistic examples from actual codebase
- Reference analyzed symbols in draft metadata
- Preserve existing navigation structure when publishing
- Ask before overwriting existing files
## Hard gates (sequenced)
Do not skip ahead: each **Pass** must be true before the next step. Use commands or explicit artifacts—not internal assurance.
### Generate draft (Mode 1)
1. **Context gate — Pass:** Step 0 commands ran (or equivalent) and you recorded at least one concrete outcome: e.g. `docs/` listing snippet, or explicit note that `docs/` is missing and will be created.
2. **Type gate — Pass:** Reference vs How-To is decided using the keyword table **or** the user’s explicit answer (quote or paraphrase with “user chose …”). Do not start **Step 3: Analyze Code** until this is locked.
3. **Skills gate — Pass:** Before analysis, both are in play: `beagle-docs:docs-style` and the type skill (`beagle-docs:reference-docs` or `beagle-docs:howto-docs`). In your run, name the two skills loaded (IDs/paths)—not “I reviewed writing guidelines.”
4. **Write gate — Pass:** After writing the draft, `test -f docs/drafts/{slug}.md` succeeds (or `ls` shows the file). Only then emit the **Draft Created** block.
### Publish draft (Mode 2)
1. **Destination gate — Pass:** User chose a destination (from the menu or a specific path). Resolve `{destination}` to a full path; **Pass** when the parent directory exists (`test -d "$(dirname "$path")"` or project-appropriate check) **and** you are not overwriting an existing file without explicit user approval.
2. **Move gate — Pass:** After `mv`, the file exists at `{destination}/{slug}.md` (`test -f`) and navigation updates (if applicable) are applied before claiming **Published**.
Applies fixes from a prior review-llm-artifacts run, with safe/risky classification
---
name: fix-llm-artifacts
description: Applies fixes from a prior review-llm-artifacts run, with safe/risky classification
disable-model-invocation: true
---
# Fix LLM Artifacts
Apply fixes from a previous `review-llm-artifacts` run with automatic safe/risky classification.
## Usage
```
/beagle-core:fix-llm-artifacts [--dry-run] [--all] [--category <name>]
```
**Flags:**
- `--dry-run` - Show what would be fixed without changing files
- `--all` - Fix entire codebase (runs review with --all first)
- `--category <name>` - Only fix specific category: `tests|dead-code|abstraction|style`
## Instructions
### 1. Parse Arguments
Extract flags from `$ARGUMENTS`:
- `--dry-run` - Preview mode only
- `--all` - Full codebase scan
- `--category <name>` - Filter to specific category
### 2. Pre-flight Safety Checks
```bash
# Check for uncommitted changes
git status --porcelain
```
If working directory is dirty, warn:
```
Warning: You have uncommitted changes. Creating a git stash before proceeding.
Run `git stash pop` to restore if needed.
```
Create stash if dirty:
```bash
git stash push -m "beagle-core: pre-fix-llm-artifacts backup"
```
### 3. Load Review Results
Check for existing review file:
```bash
cat .beagle/llm-artifacts-review.json 2>/dev/null
```
**If file missing:**
- If `--all` flag: Run `review-llm-artifacts --all --json` first
- Otherwise: Fail with: "No review results found. Run `/beagle-core:review-llm-artifacts` first."
**If file exists, validate freshness:**
```bash
# Get stored git HEAD from JSON
stored_head=$(jq -r '.git_head' .beagle/llm-artifacts-review.json)
current_head=$(git rev-parse HEAD)
if [ "$stored_head" != "$current_head" ]; then
echo "Warning: Review was run at commit $stored_head, but HEAD is now $current_head"
fi
```
If stale, prompt: "Review results are stale. Re-run review? (y/n)"
### 4. Partition Findings by Safety
Parse findings from JSON and classify by `fix_safety` field:
**Safe Fixes** (auto-apply):
- `unused_import` - Unused imports
- `todo_comment` - Stale TODO/FIXME comments
- `dead_code_obvious` - Obviously unreachable code
- `verbose_comment` - Overly verbose LLM-style comments
- `redundant_type` - Redundant type annotations
**Risky Fixes** (require confirmation):
- `test_refactor` - Test structure changes
- `abstraction_change` - Class/function extraction
- `code_removal` - Removing functional code
- `mock_boundary` - Test mock scope changes
- `logic_change` - Any behavioral modifications
### 5. Apply Safe Fixes
If `--dry-run`:
```markdown
## Safe Fixes (would apply automatically)
| File | Line | Type | Description |
|------|------|------|-------------|
| src/api.py | 15 | unused_import | Remove `from typing import List` |
| src/models.py | 42 | verbose_comment | Remove 23-line docstring |
...
```
Otherwise, spawn parallel agents per category with `Task` tool:
```
Task: Apply safe fixes for category "{category}"
Files: [list of files with findings in this category]
Instructions: Apply each fix, preserving surrounding code. Report success/failure per fix.
```
Categories to parallelize:
- `style` - Comments, formatting
- `dead-code` - Imports, unreachable code
- `tests` - Test-related safe fixes
- `abstraction` - Safe refactors
### 6. Handle Risky Fixes
For each risky fix, prompt interactively:
```
[src/services/auth.py:156] Remove seemingly unused authenticate_legacy() method?
This method has no callers in the codebase but may be used externally.
(y)es / (n)o / (s)kip all risky:
```
Track user choices:
- `y` - Apply this fix
- `n` - Skip this fix
- `s` - Skip all remaining risky fixes
### 7. Post-Fix Verification
Detect project type and run appropriate linters:
**Python:**
```bash
# Check if ruff config exists
if [ -f "pyproject.toml" ] || [ -f "ruff.toml" ]; then
ruff check --fix .
ruff format .
fi
# Check if mypy config exists
if [ -f "pyproject.toml" ] || [ -f "mypy.ini" ]; then
mypy .
fi
```
**TypeScript/JavaScript:**
```bash
# Check for eslint
if [ -f "eslint.config.js" ] || [ -f ".eslintrc.json" ]; then
npx eslint --fix .
fi
# Check for TypeScript
if [ -f "tsconfig.json" ]; then
npx tsc --noEmit
fi
```
**Go:**
```bash
if [ -f "go.mod" ]; then
go vet ./...
go build ./...
fi
```
### 8. Run Tests
```bash
# Python
if [ -f "pyproject.toml" ] || [ -f "pytest.ini" ]; then
pytest
fi
# JavaScript/TypeScript
if [ -f "package.json" ]; then
npm test 2>/dev/null || yarn test 2>/dev/null || true
fi
# Go
if [ -f "go.mod" ]; then
go test ./...
fi
```
### 9. Report Results
```markdown
## Fix Summary
### Applied Fixes
- [x] src/api.py:15 - Removed unused import `List`
- [x] src/models.py:42-64 - Removed verbose docstring
- [x] src/auth.py:156-189 - Removed dead method (user confirmed)
### Skipped Fixes
- [ ] src/services/cache.py:23 - User declined risky fix
- [ ] tests/test_api.py:45 - Test refactor skipped
### Verification Results
- Linter: PASSED
- Type check: PASSED
- Tests: PASSED (42 passed, 0 failed)
### Diff Summary
```bash
git diff --stat
```
## Cleanup
On successful completion (all verifications pass):
```bash
rm .beagle/llm-artifacts-review.json
```
If any verification fails, keep the file and report:
```
Review file preserved at .beagle/llm-artifacts-review.json
Fix issues and re-run, or restore with: git stash pop
```
## Example
```bash
# Preview all fixes without applying
/beagle-core:fix-llm-artifacts --dry-run
# Fix only dead code issues
/beagle-core:fix-llm-artifacts --category dead-code
# Full codebase scan and fix
/beagle-core:fix-llm-artifacts --all
# Fix style issues only, preview first
/beagle-core:fix-llm-artifacts --category style --dry-run
```
Fetch review comments from a PR and evaluate with receive-feedback skill
---
name: fetch-pr-feedback
description: Fetch review comments from a PR and evaluate with receive-feedback skill
disable-model-invocation: true
---
# Fetch PR Feedback
Fetch review comments from all reviewers on the current PR, format them, and evaluate using the receive-feedback skill. Excludes the PR author and current user by default.
## Usage
```bash
/beagle-core:fetch-pr-feedback [--pr <number>] [--include-author]
```
**Flags:**
- `--pr <number>` - PR number to target (default: current branch's PR)
- `--include-author` - Include PR author's own comments (default: excluded)
## Instructions
### 1. Parse Arguments
Extract flags from `$ARGUMENTS`:
- `--pr <number>` or detect from current branch
- `--include-author` flag (boolean, default false)
### 2. Get PR Context
```bash
# If --pr was specified, use that number directly
# Otherwise, get PR for current branch:
gh pr view --json number,headRefName,url,author --jq '{number, headRefName, url, author: .author.login}'
# Get repo owner/name
gh repo view --json owner,name --jq '{owner: .owner.login, name: .name}'
# Get current authenticated user
gh api user --jq '.login'
```
Store as `$PR_NUMBER`, `$PR_AUTHOR`, `$OWNER`, `$REPO`, `$CURRENT_USER`.
**Note:** `$OWNER`, `$REPO`, etc. are placeholders. Substitute actual values from previous steps.
If no PR exists for current branch, fail with: "No PR found for current branch. Use `--pr` to specify a PR number."
### 3. Fetch Comments
Fetch both types of comments, excluding `$PR_AUTHOR` and `$CURRENT_USER` (unless `--include-author` is set). Use `--paginate` with `jq -s` to combine paginated JSON arrays into one.
Write jq filters to temp files using heredocs with single-quoted delimiters (prevents shell escaping issues with `!=`, regex patterns, and angle brackets):
**Issue comments** (summary/walkthrough posts):
```bash
cat > /tmp/issue_comments.jq << 'JQEOF'
def clean_body:
gsub("<!-- suggestion_start -->.*?<!-- suggestion_end -->"; ""; "s")
| gsub("<!--.*?-->"; ""; "s")
| gsub("<details>\\s*<summary>\\s*🧩 Analysis chain[\\s\\S]*?</details>"; ""; "s")
| gsub("<details>\\s*<summary>\\s*🤖 Prompt for AI Agents[\\s\\S]*?</details>"; ""; "s")
| gsub("<details>\\s*<summary>\\s*📝 Committable suggestion[\\s\\S]*?</details>"; ""; "s")
| gsub("<details>\\s*<summary>Past reviewee.*?</details>"; ""; "s")
| gsub("<details>\\s*<summary>Recent review details[\\s\\S]*?</details>"; ""; "s")
| gsub("<details>\\s*<summary>\\s*Tips\\b.*?</details>"; ""; "s")
| gsub("\\n?---\\n[\\s\\S]*$"; ""; "s")
| gsub("^\\s+|\\s+$"; "")
| if length > 4000 then .[:4000] + "\n\n[comment truncated]" else . end
;
[(add // []) | .[] | select(
.user.login != $pr_author and
.user.login != $current_user
)] |
map({id, user: .user.login, body: (.body | clean_body), created_at})
JQEOF
gh api --paginate "repos/$OWNER/$REPO/issues/$PR_NUMBER/comments" | \
jq -s --arg pr_author "$PR_AUTHOR" --arg current_user "$CURRENT_USER" \
-f /tmp/issue_comments.jq
```
**Review comments** (line-specific):
```bash
cat > /tmp/review_comments.jq << 'JQEOF'
def clean_body:
gsub("<!-- suggestion_start -->.*?<!-- suggestion_end -->"; ""; "s")
| gsub("<!--.*?-->"; ""; "s")
| gsub("<details>\\s*<summary>\\s*🧩 Analysis chain[\\s\\S]*?</details>"; ""; "s")
| gsub("<details>\\s*<summary>\\s*🤖 Prompt for AI Agents[\\s\\S]*?</details>"; ""; "s")
| gsub("<details>\\s*<summary>\\s*📝 Committable suggestion[\\s\\S]*?</details>"; ""; "s")
| gsub("<details>\\s*<summary>Past reviewee.*?</details>"; ""; "s")
| gsub("<details>\\s*<summary>Recent review details[\\s\\S]*?</details>"; ""; "s")
| gsub("<details>\\s*<summary>\\s*Tips\\b.*?</details>"; ""; "s")
| gsub("\\n?---\\n[\\s\\S]*$"; ""; "s")
| gsub("^\\s+|\\s+$"; "")
| if length > 4000 then .[:4000] + "\n\n[comment truncated]" else . end
;
[(add // []) | .[] | select(
.user.login != $pr_author and
.user.login != $current_user
)] |
map({
id,
user: .user.login,
path,
line_display: (
.line as $end | .start_line as $start |
if $start and $start != $end then "\($start)-\($end)"
else "\($end // .original_line)" end
),
body: (.body | clean_body),
created_at
})
JQEOF
gh api --paginate "repos/$OWNER/$REPO/pulls/$PR_NUMBER/comments" | \
jq -s --arg pr_author "$PR_AUTHOR" --arg current_user "$CURRENT_USER" \
-f /tmp/review_comments.jq
```
If `--include-author` is set, omit the `--arg pr_author` parameter and the `.user.login != $pr_author` condition from both jq filter files. Keep the `$current_user` exclusion either way.
### 4. Format Feedback Document
**Noise stripping** — handled by the `clean_body` jq function in Step 3. Order matters: `<!-- suggestion_start -->...<!-- suggestion_end -->` blocks are removed first, then remaining HTML comments, then known-noise `<details>` blocks (Analysis chain, Prompt for AI Agents, Committable suggestion, Past reviewee, Recent review details, Tips), and finally the `---` footer boilerplate. The `<details>` blocks must be stripped **before** the `---` footer pattern because bot analysis chains contain `---` separators that would otherwise truncate the actual finding. Substantive `<details>` blocks (e.g. "Suggested fix", "Proposed fix") are preserved. Comments exceeding 4000 chars after stripping are truncated with a `[comment truncated]` marker.
**Group by reviewer** — organize the formatted output by reviewer username:
```markdown
# PR #$PR_NUMBER Review Feedback
## Reviewer: coderabbitai[bot]
### Summary Comments
[Issue comments from this reviewer, each separated by ---]
### Line-Specific Comments
[Review comments from this reviewer, each formatted as:]
**File: `path/to/file.ts:42`**
[cleaned comment body]
---
## Reviewer: another-reviewer
### Summary Comments
...
### Line-Specific Comments
...
```
If no comments found from any reviewer, output: "No review comments found on this PR (excluding PR author and current user)."
### 5. Evaluate with receive-feedback
Use the Skill tool to load the receive-feedback skill: `Skill(skill: "beagle-core:receive-feedback")`
Then process the formatted feedback document:
1. Parse each actionable item from the formatted document
2. Process each item through verify → evaluate → execute
3. Produce structured response summary
## Example
```bash
# Fetch all reviewer comments on current branch's PR (default)
/beagle-core:fetch-pr-feedback
# Fetch from a specific PR
/beagle-core:fetch-pr-feedback --pr 123
# Include PR author's own comments
/beagle-core:fetch-pr-feedback --include-author
# Combined
/beagle-core:fetch-pr-feedback --pr 456 --include-author
```
commit and push all local changes to remote repo
--- name: commit-push description: commit and push all local changes to remote repo disable-model-invocation: true --- # Commit and Push Commit all local changes following Conventional Commits format and push to remote. ## Gates Complete **in order**. Do not run the next action until the **Pass** condition is satisfied (use command output as evidence, not memory). 1. **Diff understood** — **Pass when:** Outputs from `git status`, `git diff`, and `git diff --cached` are consistent with your one-sentence description of what changed (or you recorded that there is nothing to commit). 2. **Commit line chosen** — **Pass when:** You have a draft first line `type(scope): description` (or `type: description` if omitting scope) that matches the change set you intend to ship. 3. **Staging matches intent** — **Pass when:** After `git add`, `git diff --cached --stat` (and spot-check `git diff --cached` if needed) shows only the paths you meant to include; adjust staging before committing if not. 4. **Push target confirmed** — **Pass when:** Current branch and remote are the ones you intend (`git branch -vv`, `git remote -v`); then push. 5. **Remote caught up** — **Pass when:** `git status` is clean and `git status -sb` shows the branch is up to date with its configured upstream (no unexpected unpushed commits left for this task). ## Step 1: Gather Context Run these commands in parallel to understand the changes: ```bash # See all untracked and modified files git status # See staged and unstaged changes git diff git diff --cached # See recent commit messages for style reference git log --oneline -10 ``` ## Step 2: Analyze Changes Review the changes and determine: - **Type**: What kind of change is this? - `feat` - New feature or capability - `fix` - Bug fix - `docs` - Documentation only - `refactor` - Code restructure without behavior change - `test` - Adding or updating tests - `chore` - Maintenance, dependency updates - `perf` - Performance improvement - `ci` - CI/CD changes - **Scope**: Which component is affected? - Examine the changed files and determine the appropriate scope - Use consistent scope names within the project (check `git log` for patterns) - *(omit scope for cross-cutting changes)* - **Breaking**: Does this break backward compatibility? If yes, add **!** after scope. ## Step 3: Write Commit Message Format: ``` type(scope): description [optional body explaining why, not what] [optional footer with issue references] ``` Rules: - Use imperative mood: "add feature" not "added feature" - Keep first line under 72 characters - Focus on *why* in the body, the diff shows *what* - Reference issues: `Closes #123` or `Fixes #456` ## Step 4: Stage, Commit, and Push Satisfy **Gates** 1–3 before `git commit`; satisfy **Gate** 4 before `git push`; satisfy **Gate** 5 after push. ```bash # Stage all changes (or selectively stage) git add -A # Gate 3: confirm staged set before committing git diff --cached --stat # Commit with message (use HEREDOC for multi-line) git commit -m "$(cat <<'EOF' type(scope): description Optional body explaining the motivation. Closes #123 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> EOF )" # Push to remote git push ``` ## Examples ```bash # Simple feature git commit -m "feat(api): add pagination support to list endpoints" # Bug fix with body git commit -m "$(cat <<'EOF' fix(auth): handle token expiration during long requests The previous implementation did not account for tokens expiring during the processing of long-running requests. Fixes #42 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> EOF )" # Breaking change git commit -m "$(cat <<'EOF' feat!(api): change response format for user endpoints BREAKING CHANGE: The `status` field is now an object with `state` and `message` properties instead of a plain string. Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> EOF )" ``` ## Step 5: Verify After pushing, satisfy **Gate 5**: run `git status` and `git status -sb` and confirm a clean tree and upstream sync (or an expected ahead/behind you can explain, e.g. fork workflow).
Use when you want to generate Architecture Decision Records from this session. Triggers on "write ADRs", "document our decisions", "create decision records",...
---
description: "Use when you want to generate Architecture Decision Records from this session. Triggers on \"write ADRs\", \"document our decisions\", \"create decision records\", \"record the choices we made\". Also useful after design discussions where decisions were reached but not documented. Does NOT extract decisions alone (use adr-decision-extraction) or provide MADR template (use adr-writing). Orchestrates the full workflow: subagent extraction, user confirmation, parallel generation, and verification."
name: write-adr
disable-model-invocation: true
---
# Write ADR
Generate Architecture Decision Records (ADRs) from decisions made during the current session.
## Workflow Overview
1. **Context** - Gather repository context and existing ADRs
2. **Extract** - Analyze conversation for decisions using a subagent
3. **Confirm** - Present decisions to user for selection
4. **Write** - Generate ADRs in parallel using subagents
5. **Report** - Summarize created files and status
6. **Verify** - Validate generated ADRs against Definition of Done
## Step 1: Gather Context
```bash
# Get current branch and recent commits
git branch --show-current
git log --oneline -5
# Check for existing ADRs
ls docs/adrs/ 2>/dev/null || echo "No ADR directory found"
# Count existing ADRs for numbering
find docs/adrs -name "*.md" 2>/dev/null | wc -l
```
This context helps the ADR writer:
- Reference related commits in the ADR
- Avoid duplicate ADRs for already-documented decisions
- Determine correct sequence numbering
## Step 2: Extract Decisions
Launch a subagent to analyze the current conversation for architectural decisions:
```text
Task(
description: "Analyze conversation and extract architectural decisions",
model: "sonnet",
prompt: |
Load the skill: Skill(skill: "beagle-analysis:adr-decision-extraction")
Analyze the conversation for decisions that warrant ADRs:
- Technology choices, architecture patterns, design trade-offs
- Rejected alternatives, significant implementation approaches
Return JSON:
{
"decisions": [
{
"id": 1,
"title": "Use PostgreSQL for primary datastore",
"context": "Brief context about why this came up",
"decision": "What was decided",
"alternatives": ["What was considered but rejected"],
"rationale": "Why this choice was made"
}
]
}
)
```
If the subagent returns an empty `decisions` array, skip to Step 5 with message: "No architectural decisions detected in this session."
## Step 3: Confirm with User
**Display all extracted decisions with full details**, then ask user to select:
```text
## Detected Decisions
### 1. Use PostgreSQL for primary datastore
**Confidence:** high
**Problem:** Need ACID transactions for financial records
**Decision:** PostgreSQL for user data storage
**Alternatives discussed:**
- MongoDB
- SQLite
**Rationale:** ACID compliance, team familiarity, mature ecosystem
**Source:** Discussion about database selection in planning phase
---
### 2. Implement event sourcing for audit trail
**Confidence:** medium
**Problem:** Compliance requires complete audit history
**Decision:** Event sourcing pattern for state changes
**Alternatives discussed:**
- Database triggers
- Application-level logging
**Rationale:** Immutable audit trail, temporal queries, debugging capability
**Source:** Compliance requirements discussion
---
## Selection
Which decisions should I write ADRs for?
- Enter numbers (e.g., "1,2" or "1-2"), "all", or "none" to skip
```
**Important:** Always display the full decision details (problem, decision, alternatives, rationale) from the extraction output BEFORE asking for selection. Do not truncate to just title and context.
Parse user response:
- `"all"` - Process all decisions
- `"none"` or empty - Skip with message "No ADRs will be created."
- `"1,2"` or `"1-2"` - Process specified decisions
## Step 4: Write ADRs (Parallel)
**Pre-allocate ADR numbers before launching subagents** to prevent numbering conflicts:
```bash
# Pre-allocate numbers for all confirmed decisions
# Example: If user selected 3 decisions
python skills/adr-writing/scripts/next_adr_number.py --count 3
# Output:
# 0003
# 0004
# 0005
```
**Assign each pre-allocated number to its corresponding decision** before launching subagents.
For each confirmed decision, launch an ADR Writer subagent in background with its **pre-assigned number**:
```text
Task(
description: "Write ADR for: {decision.title}",
model: "sonnet",
run_in_background: true,
prompt: |
Load the skill: Skill(skill: "beagle-analysis:adr-writing")
Write an ADR for this decision:
```json
{decision JSON}
```
**IMPORTANT: Use this pre-assigned ADR number: {assigned_number}**
Instructions:
1. Explore codebase for additional context
2. Write MADR-formatted ADR to docs/adr/
3. Use the pre-assigned number {assigned_number} - DO NOT call next_adr_number.py
4. Filename format: {assigned_number}-slugified-title.md
5. Return created file path
)
```
**Critical:** Pass the pre-allocated number to each subagent. Subagents must NOT call `next_adr_number.py` themselves - this causes duplicate numbers when running in parallel.
All subagents run in parallel. Wait for all to complete before proceeding.
## Step 5: Report Results
Collect outputs from all subagents and present summary:
```markdown
## ADR Generation Complete
| File | Decision | Status |
|------|----------|--------|
| docs/adr/0003-use-postgresql.md | Use PostgreSQL for primary datastore | Draft |
### Next Steps
- Review generated ADRs for accuracy
- Update status from "proposed" to "accepted" when finalized
### Gaps Requiring Investigation
- [List any decisions where subagent noted missing context]
```
If no decisions were processed:
```text
No ADRs were created. Run this command again after making architectural decisions.
```
## Step 6: Verify Generated ADRs
For each created ADR, validate against Definition of Done:
```markdown
## Verification Checklist
| ADR | E | C | A | D | R | Status |
|-----|---|---|---|---|---|--------|
| 0003-use-postgresql.md | ✓ | ✓ | ✓ | ⚠ | ✗ | Incomplete |
Legend: E=Evidence, C=Criteria, A=Agreement, D=Documentation, R=Realization
```
**Verification steps:**
1. Open each generated ADR file
2. Confirm filename follows `NNNN-slugified-title.md` pattern
3. **Verify YAML frontmatter exists at file start:**
- File MUST begin with `---`
- Contains `status: draft` (or valid status)
- Contains `date: YYYY-MM-DD` (actual date)
- Ends with `---` before title
- If frontmatter is missing, add it immediately
4. Review for `[INVESTIGATE]` prompts - these need follow-up
5. Verify at least 2 alternatives are documented
6. Confirm consequences section has both Good and Bad items
**If gaps exist:**
- Keep status as `draft` until gaps are resolved
- Use `[INVESTIGATE]` prompts to guide follow-up session
- Schedule review with stakeholders before changing to `accepted`
## Output Location
ADRs are written to `docs/adr/`. If no ADR directory exists, create it with an initial `0000-use-madr.md` template record.
## MADR Format Reference
```markdown
---
status: draft
date: YYYY-MM-DD
---
# {TITLE}
## Context and Problem Statement
{What is the issue motivating this decision?}
## Decision Drivers
* {driver 1}
* {driver 2}
## Decision Outcome
Chosen option: "{option}", because {reason}.
### Consequences
* Good, because {positive}
* Bad, because {negative}
```
perform 12-Factor App compliance analysis on a codebase
--- description: perform 12-Factor App compliance analysis on a codebase name: 12-factor-apps-analysis disable-model-invocation: true --- # 12-Factor App Compliance Analysis You are performing a comprehensive compliance analysis against the [12-Factor App](https://12factor.net) methodology for building SaaS applications. **Use the `12-factor-apps` skill to guide this analysis.** ## Target Codebase **Path:** $ARGUMENTS (default: current working directory) ## Analysis Scope Evaluate all 12 factors: 1. **Codebase** - One codebase tracked in revision control, many deploys 2. **Dependencies** - Explicitly declare and isolate dependencies 3. **Config** - Store config in the environment 4. **Backing Services** - Treat backing services as attached resources 5. **Build, Release, Run** - Strictly separate build and run stages 6. **Processes** - Execute the app as one or more stateless processes 7. **Port Binding** - Export services via port binding 8. **Concurrency** - Scale out via the process model 9. **Disposability** - Maximize robustness with fast startup and graceful shutdown 10. **Dev/Prod Parity** - Keep development, staging, and production as similar as possible 11. **Logs** - Treat logs as event streams 12. **Admin Processes** - Run admin/management tasks as one-off processes ## Workflow 1. **Use the skill** - Read the `12-factor-apps` skill for search patterns 2. **Run searches** - Use grep patterns from the skill for each factor 3. **Evaluate compliance** - Strong/Partial/Weak per factor 4. **Document evidence** - File:line references for findings 5. **Identify gaps** - What's missing vs. 12-Factor ideal 6. **Provide recommendations** - Actionable improvements ## Output Format ### Executive Summary | Factor | Status | Key Finding | |--------|--------|-------------| | I. Codebase | Strong/Partial/Weak | [Summary] | | II. Dependencies | Strong/Partial/Weak | [Summary] | | ... | ... | ... | **Overall:** X Strong, Y Partial, Z Weak ### Detailed Findings For each factor with gaps: - **Current State:** What exists - **Evidence:** File:line references - **Gap:** What's missing - **Recommendation:** How to improve ### Priority Recommendations 1. **High Priority** - Critical gaps affecting scalability/reliability 2. **Medium Priority** - Improvements for better compliance 3. **Low Priority** - Nice-to-have optimizations ## Rules - Use the skill's search patterns systematically - Provide file:line evidence for all findings - Be honest about compliance levels (don't inflate) - Focus on actionable recommendations - Reference the official 12-Factor App methodology
Reviews sqlx database code for compile-time query checking, connection pool management, migration patterns, and PostgreSQL-specific usage. Use when reviewing...
---
name: sqlx-code-review
description: Reviews sqlx database code for compile-time query checking, connection pool management, migration patterns, and PostgreSQL-specific usage. Use when reviewing Rust code that uses sqlx, database queries, connection pools, or migrations. Covers offline mode, type mapping, and transaction patterns.
---
# sqlx Code Review
## Review Workflow
1. **Check Cargo.toml** — Note sqlx features (`runtime-tokio`, `tls-rustls`/`tls-native-tls`, `postgres`/`mysql`/`sqlite`, `uuid`, `chrono`, `json`, `migrate`) and Rust edition (2024 changes RPIT lifetime capture and removes need for `async-trait`)
2. **Check query patterns** — Compile-time checked (`query!`, `query_as!`) vs runtime (`query`, `query_as`)
3. **Check pool configuration** — Connection limits, timeouts, idle settings
4. **Check migrations** — File naming, reversibility, data migration safety
5. **Check type mappings** — Rust types align with SQL column types
## Gates (evidence before severity)
Complete in order; do not assign **Critical** / **Major** until the gate for that claim is passed.
1. **Scope** — Identify the crate under review (`Cargo.toml` path) and the `.rs` files (or directory) you opened. **Pass:** At least one concrete path you inspected is named.
2. **sqlx / compile claims** — Before asserting issues about `query!` / `query_as!`, offline mode, `sqlx.toml`, `DATABASE_URL`, or Cargo features: open the relevant `Cargo.toml` and, if applicable, `sqlx.toml` or documented env. **Pass:** The finding cites a line or you state that those files were absent / out of scope.
3. **Finding anchors** — Each reported issue includes `[FILE:LINE]` per **Output Format**. **Pass:** No Critical or Major without a line reference.
4. **Protocol** — Load and complete `beagle-rust:review-verification-protocol` **after** gates 1–3 and **before** final severity labels. **Pass:** Protocol steps satisfied for each retained finding.
## Output Format
Report findings as:
```text
[FILE:LINE] ISSUE_TITLE
Severity: Critical | Major | Minor | Informational
Description of the issue and why it matters.
```
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Query macros, bind parameters, result mapping | [references/queries.md](references/queries.md) |
| Migrations, pool config, transaction patterns | [references/migrations.md](references/migrations.md) |
## Review Checklist
### Query Patterns
- [ ] Compile-time checked queries (`query!`, `query_as!`) used where possible
- [ ] `sqlx.toml` or `DATABASE_URL` configured for offline compile-time checking
- [ ] No string interpolation in queries (SQL injection risk) — use bind parameters (`$1`, `$2`)
- [ ] `query_as!` maps to named structs, not anonymous records, for public APIs
- [ ] `.fetch_one()`, `.fetch_optional()`, `.fetch_all()` chosen appropriately
- [ ] `.fetch()` (streaming) used for large result sets
### Connection Pool
- [ ] `PgPool` shared via `Arc` or framework state (not created per-request)
- [ ] Pool size configured for the deployment (not left at defaults in production)
- [ ] Connection acquisition timeout set
- [ ] Idle connection cleanup configured
- [ ] **Edition 2024**: Pool initialization uses `std::sync::LazyLock` (not `once_cell::sync::Lazy` or `lazy_static!`) for static pool singletons
### Transactions
- [ ] `pool.begin()` used for multi-statement operations
- [ ] Transaction committed explicitly (not relying on implicit rollback on drop)
- [ ] Errors within transactions trigger rollback before propagation
- [ ] Nested transactions use savepoints (`tx.begin()`) if needed
### Type Mapping
- [ ] `sqlx::Type` derives match database column types
- [ ] Enum representations consistent between Rust, serde, and SQL
- [ ] `Uuid`, `DateTime<Utc>`, `Decimal` types used (not strings for structured data)
- [ ] `Option<T>` used for nullable columns
- [ ] `serde_json::Value` used for JSONB columns
- [ ] No enum variants or struct fields named `gen` — reserved keyword in edition 2024 (use `r#gen` with `#[sqlx(rename = "gen")]` or choose a different name)
### Edition 2024 Compatibility
- [ ] Functions returning `-> impl Stream` or `-> impl Future` account for RPIT lifetime capture changes (all in-scope lifetimes captured by default; use `+ use<'a>` for precise control)
- [ ] Custom `FromRow` or `Type` trait impls use native `async fn` in traits where applicable (no `#[async_trait]` needed, stable since Rust 1.75)
- [ ] Prefer `#[expect(unused)]` over `#[allow(unused)]` for compile-time query fields only used in some code paths (self-cleaning lint suppression, stable since 1.81)
- [ ] Static pool initialization uses `std::sync::LazyLock` (not `once_cell` or `lazy_static!`)
### Migrations
- [ ] Migration files follow naming convention (`YYYYMMDDHHMMSS_description.sql`)
- [ ] Destructive migrations (DROP, ALTER DROP COLUMN) are reversible or have data backup plan
- [ ] No data-dependent schema changes in same migration as data changes
- [ ] `sqlx::migrate!()` called at application startup
## Severity Calibration
### Critical
- String interpolation in SQL queries (SQL injection)
- Missing transaction for multi-statement writes (partial writes on error)
- Connection pool created per-request (connection exhaustion)
- Missing bind parameter escaping
### Major
- Runtime queries (`query()`) where compile-time (`query!()`) could verify correctness
- Missing transaction rollback on error paths
- Enum type mismatch between Rust and database
- Unbounded `.fetch_all()` on potentially large tables
- Field or variant named `gen` without `r#gen` escape (edition 2024 compile failure)
### Minor
- Pool defaults used in production without tuning
- Missing `.fetch_optional()` (using `.fetch_one()` then handling error for "not found")
- Overly broad `SELECT *` when only specific columns needed
- Missing indexes for queried columns (flag only if query pattern is clearly slow)
- **Edition 2024**: `once_cell::sync::Lazy` or `lazy_static!` used where `std::sync::LazyLock` works
- Using `#[allow(unused)]` instead of `#[expect(unused)]` for query fields (prefer self-cleaning lint suppression)
### Informational
- Suggestions to use `query_as!` for type-safe result mapping
- Suggestions to add database-level constraints alongside Rust validation
- Migration organization improvements
## Valid Patterns (Do NOT Flag)
- **Runtime `query()` for dynamic queries** — Compile-time checking doesn't work with dynamic SQL
- **`sqlx::FromRow` derive** — Valid alternative to `query_as!` for reusable row types
- **`TEXT` columns for enum storage** — Valid with `sqlx::Type` derive, simpler than custom SQL types
- **`.execute()` ignoring row count** — Acceptable for idempotent operations (upserts, deletes)
- **Shared DB with other languages** — e.g., Elixir owns migrations, Rust reads. This is a valid architecture.
- **`r#gen` with `#[sqlx(rename = "gen")]`** — Correct edition 2024 workaround for `gen` columns in database types
- **`+ use<'a>` on query helper return types** — Precise RPIT lifetime capture (edition 2024)
- **`std::sync::LazyLock` for static pool initialization** — Replaces `once_cell`/`lazy_static` (stable since Rust 1.80)
- **Native `async fn` in custom `FromRow`/`Type` trait impls** — `async-trait` crate no longer needed (stable since Rust 1.75)
## Before Submitting Findings
Complete **Gates (evidence before severity)**, then load and follow `beagle-rust:review-verification-protocol` before reporting any issue.
FILE:references/migrations.md
# Migrations and Pool Management
## Connection Pool Configuration
### Creating the Pool
```rust
use sqlx::postgres::PgPoolOptions;
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(5)
.acquire_timeout(Duration::from_secs(5))
.idle_timeout(Duration::from_secs(600))
.max_lifetime(Duration::from_secs(1800))
.connect(&database_url)
.await?;
```
### Edition 2024: Static Pool with `LazyLock`
For applications that initialize a global pool once, use `std::sync::LazyLock` instead of `once_cell::sync::Lazy` or `lazy_static!`. `LazyLock` is in std since Rust 1.80.
```rust
// BAD — third-party crate, unnecessary dependency
use once_cell::sync::Lazy;
static POOL: Lazy<PgPool> = Lazy::new(|| {
tokio::runtime::Handle::current().block_on(async {
PgPoolOptions::new()
.max_connections(20)
.connect(&std::env::var("DATABASE_URL").unwrap())
.await
.unwrap()
})
});
// GOOD — std library, no extra dependency
use std::sync::LazyLock;
static POOL: LazyLock<PgPool> = LazyLock::new(|| {
tokio::runtime::Handle::current().block_on(async {
PgPoolOptions::new()
.max_connections(20)
.connect(&std::env::var("DATABASE_URL").unwrap())
.await
.unwrap()
})
});
```
Note: Framework-managed state (e.g., axum `State<PgPool>`) is still preferred over global statics. Use `LazyLock` only when a static singleton is genuinely needed.
### Pool Sizing Guidelines
- **Web servers:** 2-4× the number of async worker threads
- **Background workers:** Match to the number of concurrent jobs
- **Default (5):** Too low for most production workloads
- **Maximum:** Don't exceed the database's `max_connections` minus connections reserved for admin/monitoring
### Common Mistake: Pool Per Request
```rust
// BAD - creates a new pool (and connections) per request
async fn handle(req: Request) -> Response {
let pool = PgPool::connect(&url).await.unwrap();
let user = query_as!(User, "...", id).fetch_one(&pool).await?;
// pool dropped, connections closed
}
// GOOD - share pool via application state
async fn handle(State(pool): State<PgPool>, req: Request) -> Response {
let user = query_as!(User, "...", id).fetch_one(&pool).await?;
}
```
## Transactions
### Basic Pattern
```rust
let mut tx = pool.begin().await?;
sqlx::query!("INSERT INTO orders (user_id, total) VALUES ($1, $2)", user_id, total)
.execute(&mut *tx)
.await?;
sqlx::query!("UPDATE inventory SET count = count - $1 WHERE item_id = $2", qty, item_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
```
If `tx` is dropped without calling `.commit()`, the transaction rolls back automatically. This is a safety net — explicit commit is clearer.
### Error Handling in Transactions
```rust
async fn create_order(pool: &PgPool, order: NewOrder) -> Result<Order, Error> {
let mut tx = pool.begin().await?;
let order = sqlx::query_as!(Order, "INSERT INTO orders ... RETURNING *", ...)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
// tx will rollback on drop
Error::OrderCreate { source: e }
})?;
for item in &order.items {
sqlx::query!("INSERT INTO order_items ...", ...)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(order)
}
```
### Savepoints (Nested Transactions)
```rust
let mut tx = pool.begin().await?;
// Outer operation
sqlx::query!("INSERT INTO audit_log ...").execute(&mut *tx).await?;
// Inner operation that might fail independently
let savepoint = tx.begin().await?; // creates a savepoint
match try_optional_operation(&mut *savepoint).await {
Ok(_) => savepoint.commit().await?,
Err(_) => {
// savepoint rolls back, outer transaction continues
tracing::warn!("optional operation failed, continuing");
}
}
tx.commit().await?;
```
## Migrations
### File Structure
```
migrations/
├── 20240115100000_create_users.sql
├── 20240115100001_create_orders.sql
└── 20240220150000_add_user_email.sql
```
### Running Migrations
```rust
// At application startup
sqlx::migrate!("./migrations")
.run(&pool)
.await?;
```
### Migration Safety Rules
1. **Never modify an applied migration** — Create a new one instead
2. **Separate schema and data migrations** — Mixing DDL and DML in one migration increases lock contention and makes rollbacks operationally risky
3. **Make migrations idempotent where possible** — `IF NOT EXISTS`, `IF EXISTS`
4. **Destructive migrations need a plan** — `DROP COLUMN` and `DROP TABLE` should be preceded by a data migration that archives/moves data
### Reversible Migration Pattern
```sql
-- 20240220150000_add_user_email.sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;
-- To reverse (keep as documentation or in a separate down file):
-- ALTER TABLE users DROP COLUMN IF EXISTS email;
```
## Review Questions
1. Is the pool shared across the application (not created per-request)?
2. Is pool size configured appropriately for the deployment?
3. Are transactions used for multi-statement writes?
4. Are transactions committed explicitly?
5. Are migrations safe (no data loss, idempotent, separate DDL/DML)?
6. Is `sqlx::migrate!()` called at startup?
7. (Edition 2024) Does static pool initialization use `std::sync::LazyLock` instead of `once_cell` or `lazy_static!`?
FILE:references/queries.md
# Queries
## Compile-Time Checked Queries
sqlx verifies queries against the database schema at compile time. This catches column name typos, type mismatches, and invalid SQL before runtime.
### query! Macro
Returns an anonymous struct with fields matching the query columns.
```rust
// Compile-time checked — column names and types verified
let row = sqlx::query!(
"SELECT id, name, email FROM users WHERE id = $1",
user_id
)
.fetch_one(&pool)
.await?;
let name: String = row.name;
let email: Option<String> = row.email; // nullable column → Option
```
### query_as! Macro
Maps results directly to a named struct. Preferred for reusable result types.
```rust
#[derive(Debug)]
struct User {
id: Uuid,
name: String,
email: Option<String>,
created_at: DateTime<Utc>,
}
let user = sqlx::query_as!(
User,
"SELECT id, name, email, created_at FROM users WHERE id = $1",
user_id
)
.fetch_optional(&pool)
.await?;
```
### Offline Mode
For CI/CD where the database isn't available, sqlx caches query metadata. In sqlx 0.8+, configuration lives in `sqlx.toml` and cached metadata is stored in the `.sqlx/` directory. Older versions used `sqlx-data.json`.
```bash
# Generate cache from live database
cargo sqlx prepare
# Build uses cached metadata when DATABASE_URL is absent
cargo build
```
In sqlx 0.8+, you can configure offline mode and other settings via `sqlx.toml`:
```toml
# sqlx.toml
[common]
offline = true
column-override = {}
```
## Fetch Methods
| Method | Returns | Use When |
|--------|---------|----------|
| `.fetch_one()` | `T` (error if 0 or 2+ rows) | Exactly one row expected (by PK) |
| `.fetch_optional()` | `Option<T>` | Zero or one row expected |
| `.fetch_all()` | `Vec<T>` | Small, bounded result set |
| `.fetch()` | `Stream<Item = Result<T>>` | Large or unbounded results |
### Common Mistake: fetch_one for Lookups
```rust
// BAD - returns Err(RowNotFound) on "not found" which is an expected case
let user = sqlx::query_as!(User, "SELECT ... WHERE id = $1", id)
.fetch_one(&pool)
.await?; // RowNotFound error for missing users
// GOOD - "not found" is a normal case, not an error
let user = sqlx::query_as!(User, "SELECT ... WHERE id = $1", id)
.fetch_optional(&pool)
.await?;
match user {
Some(user) => Ok(user),
None => Err(Error::NotFound(id)),
}
```
### Streaming Large Results
```rust
use futures::TryStreamExt;
let mut stream = sqlx::query_as!(Event, "SELECT * FROM events WHERE workflow_id = $1", wf_id)
.fetch(&pool);
while let Some(event) = stream.try_next().await? {
process(event).await;
}
```
### Edition 2024: RPIT Lifetime Capture in Query Helpers
In edition 2024, `-> impl Trait` captures all in-scope lifetimes by default. This affects functions that return streams or futures from sqlx queries.
```rust
// Edition 2021 — worked because `-> impl Stream` didn't capture 'a
fn get_events<'a>(pool: &'a PgPool, wf_id: Uuid) -> impl Stream<Item = Result<Event, sqlx::Error>> {
sqlx::query_as!(Event, "SELECT * FROM events WHERE workflow_id = $1", wf_id)
.fetch(pool)
}
// Edition 2024 — captures 'a by default, which is usually correct here.
// If you need to NOT capture a lifetime, use precise capture syntax:
fn get_events<'a>(pool: &'a PgPool, wf_id: Uuid) -> impl Stream<Item = Result<Event, sqlx::Error>> + use<'a> {
sqlx::query_as!(Event, "SELECT * FROM events WHERE workflow_id = $1", wf_id)
.fetch(pool)
}
```
Most sqlx query helpers that borrow the pool *should* capture the pool lifetime, so the edition 2024 default is usually correct. Flag cases where the return type is stored in a struct that outlives the borrow.
## Bind Parameters
Always use bind parameters (`$1`, `$2` for Postgres; `?` for MySQL/SQLite). Never interpolate values into query strings.
```rust
// BAD - SQL injection vulnerability
let query = format!("SELECT * FROM users WHERE name = '{}'", name);
sqlx::query(&query).fetch_one(&pool).await?;
// GOOD - parameterized query
sqlx::query("SELECT * FROM users WHERE name = $1")
.bind(&name)
.fetch_one(&pool)
.await?;
// BEST - compile-time checked
sqlx::query!("SELECT * FROM users WHERE name = $1", name)
.fetch_one(&pool)
.await?;
```
## Type Mapping
### Rust ↔ PostgreSQL
| Rust Type | PostgreSQL Type |
|-----------|-----------------|
| `i32` | `INT4` / `INTEGER` |
| `i64` | `INT8` / `BIGINT` |
| `f64` | `FLOAT8` / `DOUBLE PRECISION` |
| `Decimal` | `NUMERIC` / `DECIMAL` |
| `String` | `TEXT` / `VARCHAR` |
| `bool` | `BOOL` |
| `Uuid` | `UUID` |
| `DateTime<Utc>` | `TIMESTAMPTZ` |
| `NaiveDateTime` | `TIMESTAMP` |
| `serde_json::Value` | `JSONB` / `JSON` |
| `Vec<u8>` | `BYTEA` |
| `Option<T>` | Nullable column |
### Custom Enum Types
```rust
#[derive(Debug, Clone, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "varchar", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum Status {
Pending,
InProgress,
Complete,
Failed,
}
```
Ensure `rename_all` matches between `sqlx::Type` and `serde` — mismatches cause silent bugs where data written by one system can't be read by the other.
### Edition 2024: Reserved `gen` Keyword
In edition 2024, `gen` is a reserved keyword. Any sqlx enum variant or struct field named `gen` will fail to compile. Use `r#gen` as the Rust identifier and `#[sqlx(rename)]` to preserve the database column name.
```rust
// BAD — fails to compile on edition 2024
#[derive(sqlx::Type)]
#[sqlx(type_name = "varchar", rename_all = "snake_case")]
pub enum GenerationType {
Manual,
Gen, // compile error: `gen` is a reserved keyword
}
// GOOD — compiles on edition 2024, database value unchanged
#[derive(sqlx::Type)]
#[sqlx(type_name = "varchar", rename_all = "snake_case")]
pub enum GenerationType {
Manual,
#[sqlx(rename = "gen")]
r#Gen,
}
```
### Edition 2024: `#[expect]` for Lint Suppression
Prefer `#[expect(unused)]` over `#[allow(unused)]` for struct fields that exist only for sqlx mapping but aren't read directly. The `#[expect]` attribute warns when the suppression becomes unnecessary, keeping lint overrides self-cleaning.
```rust
// BAD — silent if the field starts being used elsewhere
#[allow(dead_code)]
struct AuditRow {
id: i64,
raw_payload: serde_json::Value,
}
// GOOD — warns when suppression is no longer needed
#[expect(dead_code)]
struct AuditRow {
id: i64,
raw_payload: serde_json::Value,
}
```
## Review Questions
1. Are queries compile-time checked where possible?
2. Is `.fetch_optional()` used for lookups that may return no rows?
3. Are bind parameters used (no string interpolation)?
4. Is `.fetch()` streaming used for large result sets?
5. Do Rust types match PostgreSQL column types?
6. Are enum representations consistent between sqlx and serde?
7. (Edition 2024) Do any enum variants or fields use `gen` as an identifier without `r#gen`?
8. (Edition 2024) Do functions returning `-> impl Stream`/`-> impl Future` account for RPIT lifetime capture changes?
Reviews serde serialization code for derive patterns, enum representations, custom implementations, and common serialization bugs. Use when reviewing Rust co...
---
name: serde-code-review
description: Reviews serde serialization code for derive patterns, enum representations, custom implementations, and common serialization bugs. Use when reviewing Rust code that uses serde, serde_json, toml, or any serde-based serialization format. Covers attribute macros, field renaming, and format-specific pitfalls.
---
# Serde Code Review
## Review Workflow
1. **Check Cargo.toml** — Note serde features (`derive`, `rc`), format crates (`serde_json`, `toml`, `bincode`, etc.), and Rust edition (2024 has breaking changes affecting serde code)
2. **Check derive usage** — Verify `Serialize` and `Deserialize` are derived appropriately
3. **Check enum representations** — Enum tagging affects wire format compatibility and readability
4. **Check field attributes** — Renaming, defaults, skipping affect API contracts
5. **Check edition 2024 compatibility** — Reserved `gen` keyword, RPIT lifetime capture changes, `never_type_fallback`
6. **Verify round-trip correctness** — Serialized data must deserialize back to the same value
## Gates (before reporting findings)
Run **in order**. Do not write a finding until the step that applies has passed.
1. **Serde context on disk** — **Pass when:** You have read the relevant `Cargo.toml` (crate or workspace root) and can state Rust `edition`, `serde` / `serde_derive` features if non-default (`derive`, `rc`), and which format crates apply (`serde_json`, `toml`, `bincode`, etc.) for the code under review. **Then** apply edition-specific checklist items (e.g. `gen`, RPIT/`never_type_fallback`) only when that file supports them.
2. **Per-finding evidence** — **Pass when:** Each issue cites `[FILE:LINE]` from the **current** tree for the `struct`/`enum`, `Serialize`/`Deserialize` impl, or attribute block in question (not from memory, docs-only, or another branch).
3. **Category check vs protocol** — **Pass when:** For the finding type (derive attrs, enum tagging, `flatten`, custom impl, sqlx + serde alignment), you ran the matching checks from `beagle-rust:review-verification-protocol` (e.g. full type definition + serde attrs before “wrong representation”; confirmed edition in `Cargo.toml` before edition-2024-only findings). **Then** add the finding.
4. **Output shape** — **Pass when:** The report lines match **Output Format** below (severity + description).
## Output Format
Report findings as:
```text
[FILE:LINE] ISSUE_TITLE
Severity: Critical | Major | Minor | Informational
Description of the issue and why it matters.
```
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Derive patterns, attribute macros, field configuration | [references/derive-patterns.md](references/derive-patterns.md) |
| Custom Serialize/Deserialize, format-specific issues | [references/custom-serialization.md](references/custom-serialization.md) |
## Review Checklist
### Derive Usage
- [ ] `#[derive(Serialize, Deserialize)]` on types that cross serialization boundaries
- [ ] `#[derive(Debug)]` alongside serde derives (debugging serialization issues)
- [ ] Feature-gated derives when serde is optional: `#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]`
- [ ] Prefer `#[expect(unused)]` over `#[allow(unused)]` for serde-only fields (self-cleaning lint suppression, stable since 1.81)
### Enum Representation
- [ ] Enum tagging is explicit (not relying on serde's default externally-tagged format when another is intended)
- [ ] Tag names are stable and won't collide with field names
- [ ] `#[serde(rename_all = "...")]` used consistently across the API
### Field Configuration
- [ ] `#[serde(skip_serializing_if = "Option::is_none")]` for optional fields (clean JSON output)
- [ ] `#[serde(default)]` for fields that should have fallback values during deserialization
- [ ] `#[serde(rename = "...")]` when Rust field names differ from wire format
- [ ] `#[serde(flatten)]` used judiciously (can cause key collisions)
- [ ] No `#[serde(deny_unknown_fields)]` on types that need forward compatibility
- [ ] No fields or variants named `gen` — reserved keyword in edition 2024 (use `r#gen` or rename)
### Database Integration (sqlx)
- [ ] `#[derive(sqlx::Type)]` enums use consistent representation with serde
- [ ] Enum variant casing matches between serde (`rename_all`) and sqlx (`rename_all`)
### Edition 2024 Compatibility
- [ ] No fields or enum variants named `gen` (reserved keyword — use `r#gen` with `#[serde(rename = "gen")]` or choose a different name)
- [ ] Custom `Serialize`/`Deserialize` impls returning `impl Trait` account for RPIT lifetime capture changes (all in-scope lifetimes captured by default; use `+ use<'a>` for precise control)
- [ ] Deserialization error paths handle `never_type_fallback` — `!` falls back to `!` instead of `()`, which affects match exhaustiveness on `Result<T, !>` patterns
### Correctness
- [ ] Round-trip tests exist for complex types (serialize → deserialize → assert_eq)
- [ ] `PartialEq` derived for types with round-trip tests
- [ ] No lossy conversions (e.g., `f64` → `i64` in JSON numbers)
- [ ] `Decimal` used for money/precision-sensitive values, not `f64`
## Severity Calibration
### Critical
- Enum representation mismatch between serializer and deserializer (data loss)
- Missing `#[serde(rename)]` causing API-breaking field name changes
- `#[serde(flatten)]` causing silent key collisions
- Lossy numeric conversions (`f64` precision loss for monetary values)
### Major
- Inconsistent `rename_all` across related types (confusing API)
- Missing `skip_serializing_if` causing null/empty noise in output
- `deny_unknown_fields` on types consumed by evolving APIs (breaks forward compatibility)
- Missing round-trip tests for complex enum representations
- Field or variant named `gen` without `r#gen` escape (edition 2024 compile failure)
### Minor
- Unnecessary `#[serde(default)]` on required fields
- Using string representation for enums when numeric would be more efficient
- Verbose custom implementations where derive + attributes suffice
- Using `#[allow(unused)]` instead of `#[expect(unused)]` for serde-only fields (prefer self-cleaning lint suppression)
### Informational
- Suggestions to switch enum representation for cleaner wire format
- Suggestions to add `#[non_exhaustive]` alongside serde for forward compatibility
## Valid Patterns (Do NOT Flag)
- **Externally tagged enums** — serde's default, valid for many use cases
- **`#[serde(untagged)]` enums** — Valid when discriminated by structure, not by tag
- **`serde_json::Value` for dynamic data** — Appropriate for truly schema-less fields
- **`#[serde(skip)]` on computed fields** — Correct for derived/cached values
- **`#[serde(with = "...")]` for custom formats** — Standard for dates, UUIDs, etc.
- **`r#gen` with `#[serde(rename = "gen")]`** — Correct edition 2024 workaround for `gen` fields in wire formats
- **`+ use<'a>` on custom serializer return types** — Precise RPIT lifetime capture (edition 2024)
## Before Submitting Findings
Complete **Gates (before reporting findings)** above; gate 3 incorporates `beagle-rust:review-verification-protocol` for serde-related issue types.
FILE:references/custom-serialization.md
# Custom Serialization
## When Custom Implementation is Needed
Derive handles most cases. Custom implementations are warranted for:
- Format-specific representations (dates, UUIDs, durations)
- Validation during deserialization
- Backwards-compatible format changes
- Types from external crates without serde support
## serde(with) Module Pattern
The cleanest approach for custom field serialization. Create a module with `serialize` and `deserialize` functions:
```rust
mod iso_date {
use chrono::{DateTime, Utc};
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(date: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
where S: Serializer {
s.serialize_str(&date.to_rfc3339())
}
pub fn deserialize<'de, D>(d: D) -> Result<DateTime<Utc>, D::Error>
where D: Deserializer<'de> {
let s = String::deserialize(d)?;
DateTime::parse_from_rfc3339(&s)
.map(|dt| dt.with_timezone(&Utc))
.map_err(serde::de::Error::custom)
}
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Event {
#[serde(with = "iso_date")]
created_at: DateTime<Utc>,
}
```
For `Option<T>` fields, use `serialize_with` + `deserialize_with` separately or use the `serde_with` crate.
## Validating Deserialization
When deserialized data needs validation, implement `Deserialize` manually or use `#[serde(try_from)]`:
```rust
#[derive(Serialize, Deserialize)]
#[serde(try_from = "String")]
struct Email(String);
impl TryFrom<String> for Email {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
if s.contains('@') {
Ok(Email(s))
} else {
Err(format!("invalid email: {s}"))
}
}
}
// Deserialization now validates automatically
```
## Edition 2024: RPIT Lifetime Capture in Custom Serializers
In edition 2024, `-> impl Trait` captures ALL in-scope lifetimes by default. This affects custom serialization helpers that return `impl Trait` — particularly deserializer combinators and visitor factories.
```rust
// Edition 2021: only captures lifetimes explicitly in bounds
// Edition 2024: captures 'de AND 'a by default
fn make_visitor<'de, 'a>(context: &'a str) -> impl Visitor<'de> {
MyVisitor { context }
}
```
If you need the returned type NOT to capture a lifetime, use the precise capture syntax:
```rust
// GOOD — explicitly captures only 'de, excludes 'a
fn make_visitor<'de, 'a>(context: &'a str) -> impl Visitor<'de> + use<'de> {
MyVisitor { context: context.to_owned() }
}
```
Most serde `with` modules are unaffected because they return `Result`, not `impl Trait`. This primarily impacts advanced patterns: custom visitor factories, deserializer adapters, and combinator libraries that return opaque types.
## Edition 2024: `never_type_fallback` and Deserialization Errors
In edition 2024, the `!` (never) type falls back to `!` instead of `()`. This can surface in deserialization code that uses infallible patterns or match expressions on `Result<T, !>`:
```rust
// Edition 2021: ! falls back to (), match is exhaustive
// Edition 2024: ! falls back to !, may change type inference
// If you have a custom deserializer that returns Result<T, !> for infallible paths,
// match arms and type inference may behave differently. Prefer explicit error types:
// BAD — relies on never type fallback behavior
fn infallible_deserialize<T: Default>() -> Result<T, !> {
Ok(T::default())
}
// GOOD — use a concrete error type even for infallible paths
fn infallible_deserialize<T: Default>() -> Result<T, serde::de::value::Error> {
Ok(T::default())
}
```
In practice, most serde code uses `serde::de::Error` trait bounds and concrete error types, so this is a low-frequency issue. Flag it when you see explicit `!` in deserialization return types.
## Common Pitfalls
### Lossy Numeric Conversions
JSON numbers are IEEE 754 doubles. Values outside `f64` precision range get silently truncated.
```rust
// BAD for monetary values - f64 loses precision
#[derive(Serialize, Deserialize)]
struct Invoice {
amount: f64, // 0.1 + 0.2 ≠ 0.3
}
// GOOD - use decimal types
use rust_decimal::Decimal;
#[derive(Serialize, Deserialize)]
struct Invoice {
amount: Decimal, // exact decimal arithmetic
}
```
### Untagged Enum Ambiguity
With `#[serde(untagged)]`, serde tries variants in declaration order. If two variants can match the same input, the first wins silently.
```rust
// AMBIGUOUS - both variants match {"value": 42}
#[serde(untagged)]
enum Data {
Full { value: i64, extra: Option<String> },
Simple { value: i64 },
}
// Always deserializes as Full (tried first)
```
### deny_unknown_fields Breaks Forward Compatibility
Adding `#[serde(deny_unknown_fields)]` means older code fails to deserialize data from newer versions that add fields.
```rust
// Version 1: works fine
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Config { port: u16 }
// Version 2 adds `host` field
// Now V1 code fails on V2 config files — breaking change
```
Use `deny_unknown_fields` only for strict input validation (user-facing forms, CLI config) where unknown fields indicate user error.
## Round-Trip Testing
Every type with custom serialization should have a round-trip test:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_event() {
let original = Event {
created_at: Utc::now(),
};
let json = serde_json::to_string(&original).unwrap();
let deserialized: Event = serde_json::from_str(&json).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn deserialize_from_known_format() {
// Test against a known JSON string to catch format regressions
let json = r#"{"created_at": "2024-01-15T10:30:00Z"}"#;
let event: Event = serde_json::from_str(json).unwrap();
assert_eq!(event.created_at.year(), 2024);
}
}
```
## Review Questions
1. Are custom serialization modules (`with`) used instead of full manual implementations where possible?
2. Is `try_from` used for validating deserialization?
3. Are monetary/precision values using `Decimal`, not `f64`?
4. Are untagged enums free from variant ambiguity?
5. Is `deny_unknown_fields` avoided on evolving APIs?
6. Do custom serializations have round-trip tests?
7. Do custom serializer/deserializer helpers returning `impl Trait` account for edition 2024 RPIT lifetime capture?
8. Are deserialization error types concrete (not relying on `!` type fallback)?
FILE:references/derive-patterns.md
# Derive Patterns
## Enum Tagging Strategies
Serde supports four enum representations. The choice affects wire format, readability, and compatibility.
### Externally Tagged (Default)
```rust
#[derive(Serialize, Deserialize)]
enum Message {
Text(String),
Image { url: String, alt: String },
}
// JSON: {"Text": "hello"} or {"Image": {"url": "...", "alt": "..."}}
```
Simple but produces awkward JSON for variants with data.
### Internally Tagged
```rust
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Message {
Text { content: String },
Image { url: String, alt: String },
}
// JSON: {"type": "Text", "content": "hello"}
```
Clean and common for API types. Requires all variants to be struct-like (no tuple variants). The tag field name must not collide with variant field names.
### Adjacently Tagged
```rust
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Message {
Text(String),
Image { url: String, alt: String },
}
// JSON: {"type": "Text", "data": "hello"}
```
Supports both tuple and struct variants. Good for message protocols where type and payload are separate.
### Untagged
```rust
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum ApiResponse {
Success { data: Value },
Error { error: String, code: u32 },
}
// JSON: {"data": {...}} or {"error": "...", "code": 404}
```
Discriminated by structure, not a tag. Serde tries each variant in order until one succeeds. Watch for ambiguity — if two variants could match the same input, serde uses the first match.
## Field Attributes
### rename_all
Converts Rust's `snake_case` to the wire format's convention.
```rust
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
user_name: String, // → "userName"
created_at: DateTime, // → "createdAt"
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Status {
InProgress, // → "in_progress"
Complete, // → "complete"
}
```
Common values: `camelCase`, `snake_case`, `SCREAMING_SNAKE_CASE`, `kebab-case`, `lowercase`, `UPPERCASE`.
### skip_serializing_if
Omits fields from output when a condition is true. Essential for clean API responses.
```rust
#[derive(Serialize)]
struct User {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>,
}
// With email=None, tags=[]: {"name": "Alice"}
// Without skip: {"name": "Alice", "email": null, "tags": []}
```
### default
Provides fallback values during deserialization when a field is missing.
```rust
#[derive(Deserialize)]
struct Config {
host: String,
#[serde(default = "default_port")]
port: u16,
#[serde(default)] // uses Default::default()
debug: bool,
}
fn default_port() -> u16 { 8080 }
```
### flatten
Inlines a struct's fields into the parent. Useful for composition but can cause subtle issues.
```rust
#[derive(Serialize, Deserialize)]
struct Request {
id: Uuid,
#[serde(flatten)]
metadata: Metadata, // metadata fields appear at top level
}
#[derive(Serialize, Deserialize)]
struct Metadata {
timestamp: DateTime<Utc>,
source: String,
}
// JSON: {"id": "...", "timestamp": "...", "source": "..."}
```
**Pitfall:** If `Request` and `Metadata` have a field with the same name, one silently wins. Use `#[serde(flatten)]` only when field names are guaranteed not to collide.
## Edition 2024: Reserved `gen` Keyword
In Rust edition 2024, `gen` is a reserved keyword. Any serde field or enum variant named `gen` will fail to compile. Use `r#gen` as the Rust identifier and `#[serde(rename)]` to preserve the wire format name.
```rust
// BAD — fails to compile on edition 2024
#[derive(Serialize, Deserialize)]
struct Model {
gen: u32,
}
// GOOD — compiles on edition 2024, wire format unchanged
#[derive(Serialize, Deserialize)]
struct Model {
#[serde(rename = "gen")]
r#gen: u32,
}
// GOOD — enum variant
#[derive(Serialize, Deserialize)]
enum Phase {
#[serde(rename = "gen")]
Generation,
Evaluation,
}
```
This also applies to `#[serde(alias = "gen")]` — the alias string is fine, but the Rust identifier must use `r#gen`.
## Edition 2024: `#[expect]` for Serde-Only Fields
Fields that exist solely for deserialization (e.g., skipped during serialization) may trigger unused warnings. Prefer `#[expect(dead_code)]` over `#[allow(dead_code)]` — it warns you when the suppression becomes unnecessary.
```rust
// BAD — allow stays forever even if the field becomes used
#[allow(dead_code)]
#[serde(skip_serializing)]
legacy_id: Option<String>,
// GOOD — expect warns when suppression is no longer needed
#[expect(dead_code)]
#[serde(skip_serializing)]
legacy_id: Option<String>,
```
## Database Type Alignment
When types are used with both serde and sqlx, keep representations consistent:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "snake_case")]
#[sqlx(type_name = "varchar", rename_all = "snake_case")]
pub enum Status {
Pending,
InProgress,
Complete,
}
```
Both serde and sqlx will use `"pending"`, `"in_progress"`, `"complete"`. Mismatched casing between the two causes bugs that are hard to trace.
## Review Questions
1. Is the enum tagging strategy explicit and appropriate for the wire format?
2. Is `rename_all` consistent across related types?
3. Are optional fields using `skip_serializing_if` for clean output?
4. Does `#[serde(flatten)]` risk field name collisions?
5. Do serde and sqlx enum representations match?
6. Are any fields or variants named `gen` (edition 2024 reserved keyword)?
7. Are lint suppressions on serde-only fields using `#[expect]` instead of `#[allow]`?
Reviews Rust test code for unit test patterns, integration test structure, async testing, mocking approaches, and property-based testing. Covers Rust 2024 ed...
---
name: rust-testing-code-review
description: Reviews Rust test code for unit test patterns, integration test structure, async testing, mocking approaches, and property-based testing. Covers Rust 2024 edition changes including async fn in traits for mocks, #[expect] lint suppression, LazyLock test fixtures, and temporary scope changes affecting test assertions. Use when reviewing _test.rs files, #[cfg(test)] modules, or test infrastructure in Rust projects. Covers tokio::test, test fixtures, and assertion patterns.
---
# Rust Testing Code Review
## Review Workflow
1. **Check Rust edition** — Note edition in `Cargo.toml` (2021 vs 2024). Edition 2024 changes temporary scoping in `if let` and tail expressions, and makes `#[expect]` the preferred lint suppression
2. **Check test organization** — Unit tests in `#[cfg(test)]` modules, integration tests in `tests/` directory
3. **Check async test setup** — `#[tokio::test]` for async tests, proper runtime configuration. Check for `async-trait` on mocks that could use native `async fn` in traits
4. **Check assertions** — Meaningful messages, correct assertion type. Review `if let` assertions for edition 2024 temporary scope changes
5. **Check test isolation** — No shared mutable state between tests, proper setup/teardown. Prefer `LazyLock` over `lazy_static!`/`once_cell` for shared fixtures
6. **Check coverage patterns** — Error paths tested, edge cases covered
## Gates (hard)
Do not advance to **Output Format** until each pass condition is satisfied (yes/no with a concrete artifact).
1. **Edition recorded** — Open the target crate’s `Cargo.toml` (or workspace `[workspace.package]` / inherited edition) and note the `edition` value. **Pass:** you can quote `edition = "…"` (or document “inherited from workspace”) before citing Rust 2024–specific behavior (`if let` / tail temporary drops, `#[expect]` vs `#[allow]` migration, native `async fn` in traits as default). If edition is not `2024`, do **not** report those items as edition-2024 regressions; at most **Informational** if still useful.
2. **`dyn` vs static async mocks** — Before suggesting native `async fn` in traits instead of `async-trait`, check whether the mock is used as `dyn Trait`. **Pass:** if `dyn` is required, you either skip that suggestion or align with **Valid Patterns** (`async-trait` still needed).
3. **Verification protocol** — **Pass:** steps from `beagle-rust:review-verification-protocol` are done before any finding is listed (see **Before Submitting Findings**).
## Output Format
Report findings as:
```text
[FILE:LINE] ISSUE_TITLE
Severity: Critical | Major | Minor | Informational
Description of the issue and why it matters.
```
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Unit tests, assertions, naming, snapshots, rstest, doc tests, `#[expect]`, `LazyLock` fixtures, tail expression scope | [references/unit-tests.md](references/unit-tests.md) |
| Integration tests, async testing, fixtures, test databases, native `async fn` mocks, `if let` temporary scope | [references/integration-tests.md](references/integration-tests.md) |
| Fuzzing, property-based testing, Miri, Loom, benchmarking, compile_fail, custom harness, mocking strategies | [references/advanced-testing.md](references/advanced-testing.md) |
## Review Checklist
### Test Structure
- [ ] Unit tests in `#[cfg(test)] mod tests` within source files
- [ ] Integration tests in `tests/` directory (one file per module or feature)
- [ ] `use super::*` in test modules to access parent module items
- [ ] Test function names describe the scenario: `test_<function>_<scenario>_<expected>`
- [ ] Tests are independent — no reliance on execution order
### Async Tests
- [ ] `#[tokio::test]` used for async test functions
- [ ] `#[tokio::test(flavor = "multi_thread")]` when testing multi-threaded behavior
- [ ] No `block_on` inside async tests (use `.await` directly)
- [ ] Test timeouts set for tests that could hang
- [ ] Mock traits use native `async fn` instead of `async-trait` crate (stable since Rust 1.75)
### Assertions
- [ ] `assert_eq!` / `assert_ne!` used for value comparisons (better error messages than `assert!`)
- [ ] Custom messages on assertions that aren't self-documenting
- [ ] `matches!` macro used for enum variant checking
- [ ] Error types checked with `matches!` or pattern matching, not string comparison
- [ ] One assertion per test where practical (easier to diagnose failures)
- [ ] `if let` assertions reviewed for edition 2024 temporary scope — temporaries in conditions drop earlier, may invalidate borrows
- [ ] Tail expression returns reviewed for edition 2024 — temporaries in tail expressions drop before local variables
### Mocking and Test Doubles
- [ ] Traits used as seams for dependency injection (not concrete types)
- [ ] Mock implementations kept minimal — only what the test needs
- [ ] No mocking of types you don't own (wrap external dependencies behind your own trait)
- [ ] Test fixtures as helper functions, not global state
- [ ] `std::sync::LazyLock` used for shared test fixtures instead of `lazy_static!` or `once_cell` (stable since Rust 1.80)
### Error Path Testing
- [ ] `Result::Err` variants tested, not just happy paths
- [ ] Specific error variants checked (not just "is error")
- [ ] `#[should_panic]` used sparingly — prefer `Result`-returning tests
### Lint Suppression in Tests
- [ ] `#[expect(lint)]` used instead of `#[allow(lint)]` for test-specific suppressions (stable since Rust 1.81)
- [ ] Justification comment on every `#[expect]` or `#[allow]` in test code
- [ ] Stale `#[allow]` attributes migrated to `#[expect]` for self-cleaning behavior
### Test Naming
- [ ] Test names read like sentences describing behavior (not `test_happy_path`)
- [ ] Related tests grouped in nested `mod` blocks for organization
- [ ] Test names follow pattern: `<function>_should_<behavior>_when_<condition>`
### Snapshot Testing
- [ ] `cargo insta` used for complex structural output (JSON, YAML, HTML, CLI output)
- [ ] Snapshots are small and focused (not huge objects)
- [ ] Redactions used for unstable fields (timestamps, UUIDs)
- [ ] Snapshots committed to git in `snapshots/` directory
- [ ] Simple values use `assert_eq!`, not snapshots
### Parametrized Testing
- [ ] `rstest` used to avoid duplicated test functions for similar inputs
- [ ] `#[rstest]` with `#[case::name]` attributes for descriptive parametrized tests
- [ ] `#[fixture]` used for shared test setup when multiple tests need same construction
- [ ] Parametrized tests still have descriptive case names (not just `#[case(1)]`)
- [ ] Combined with async: `#[rstest] #[tokio::test]` for async parametrized tests
### Doc Tests
- [ ] Public API functions have `/// # Examples` with runnable code
- [ ] Doc tests serve as both documentation and correctness checks
- [ ] Hidden setup lines prefixed with `#` to keep examples clean
- [ ] `cargo test --doc` passes (nextest doesn't run doc tests)
## Severity Calibration
### Critical
- Tests that pass but don't actually verify behavior (assertions on wrong values)
- Shared mutable state between tests causing flaky results
- Missing error path tests for security-critical code
### Major
- `#[should_panic]` without `expected` message (catches any panic, including wrong ones)
- `unwrap()` in test setup that hides the real failure location
- Tests that depend on execution order
- `if let` with inline temporary in assertion that breaks under edition 2024 temporary scoping
- `async-trait` on mock traits when native `async fn` in traits is available and project targets edition 2024
### Minor
- Missing assertion messages on complex comparisons
- `assert!(x == y)` instead of `assert_eq!(x, y)` (worse error messages)
- Test names that don't describe the scenario
- Redundant setup code that could be extracted to a helper
- `#[allow]` used where `#[expect]` would provide self-cleaning suppression
- `lazy_static!` or `once_cell` used for test fixtures when `LazyLock` is available
### Informational
- Suggestions to add property-based tests via `proptest` or `quickcheck`
- Suggestions to add snapshot testing for complex output
- Coverage improvement opportunities
## Valid Patterns (Do NOT Flag)
- **`unwrap()` / `expect()` in tests** — Panicking on unexpected errors is the correct test behavior
- **`use super::*` in test modules** — Standard pattern for accessing parent items
- **`#[allow(dead_code)]` on test helpers** — Helper functions may not be used in every test
- **`clone()` in tests** — Clarity over performance
- **Large test functions** — Integration tests can be long; extracting helpers isn't always clearer
- **`assert!` for boolean checks** — Fine when the expression is clearly boolean (`.is_some()`, `.is_empty()`)
- **Multiple assertions testing one logical behavior** — Sometimes one behavior needs multiple checks
- **`unwrap()` on `Result`-returning test functions** — Propagating with `?` is also fine but not required
- **`async-trait` on mock traits requiring `dyn` dispatch** — Native `async fn` in traits doesn't support `dyn Trait`; `async-trait` is still needed there
- **`#[expect]` with justification on test helpers** — Self-cleaning lint suppression is correct in test code
- **`LazyLock` for expensive shared test fixtures** — Thread-safe lazy init is appropriate for test globals
## Before Submitting Findings
Load and follow `beagle-rust:review-verification-protocol` before reporting any issue.
FILE:references/advanced-testing.md
# Advanced Testing
## Fuzzing
Fuzzing generates semi-random inputs to find crashes. Modern fuzzers use code coverage to explore paths efficiently. Use for parsers, deserializers, codec implementations, and anything accepting untrusted input.
### cargo-fuzz with libfuzzer
```rust
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let _ = url::Url::parse(s); // looking for panics, not checking results
}
});
```
For complex types, derive `Arbitrary` to convert raw bytes into structured inputs:
```rust
#[derive(arbitrary::Arbitrary, Debug)]
struct FuzzInput { key: String, value: Vec<u8>, ttl: u32 }
fuzz_target!(|input: FuzzInput| {
let mut cache = Cache::new();
cache.insert(&input.key, &input.value, input.ttl);
});
```
**Flag when:**
- Fuzz targets exist without a `corpus/` directory (no seed inputs)
- Fuzz targets check return values instead of letting panics surface
- Parsers or protocol handlers lack fuzz targets entirely
## Property-Based Testing
Verifies invariants hold across generated inputs rather than checking specific cases.
```rust
use proptest::prelude::*;
proptest! {
#[test]
fn round_trip_serialization(input in any::<MyStruct>()) {
let bytes = input.serialize();
let decoded = MyStruct::deserialize(&bytes).unwrap();
prop_assert_eq!(input, decoded);
}
#[test]
fn sort_is_idempotent(mut v in prop::collection::vec(any::<i32>(), 0..100)) {
v.sort();
let sorted = v.clone();
v.sort();
prop_assert_eq!(v, sorted);
}
}
```
Test stateful types with operation sequences via `Vec<Op>` where `Op` is an enum of possible actions. Testers minimize failing sequences automatically.
**Flag when:**
- proptest strategies are overly narrow (e.g., `1..5` when valid range is `0..u64::MAX`)
- Property tests check only success, not invariants (no `prop_assert!`)
- Data structures lack operation-sequence testing for stateful invariants
## Miri
Miri interprets Rust's MIR to detect undefined behavior in unsafe code. Run with `cargo +nightly miri test`.
**Catches:** Uninitialized memory reads, use-after-free, out-of-bounds pointer access, invalid exclusive references (Stacked Borrows violations), misaligned accesses.
**Misses:** Data races (use Loom), logic bugs, performance issues, FFI calls to non-Rust code.
**Flag when:**
- Crate contains `unsafe` blocks but CI does not run `cargo miri test`
- Miri is disabled for tests that exercise unsafe code paths
- Raw pointer arithmetic lacks Miri coverage
## Loom
Exhaustively tests concurrent code by exploring all thread interleavings at synchronization points.
```rust
#[test]
fn concurrent_counter() {
loom::model(|| {
let counter = loom::sync::Arc::new(loom::sync::atomic::AtomicUsize::new(0));
let c1 = counter.clone();
let t = loom::thread::spawn(move || {
c1.fetch_add(1, Ordering::SeqCst);
});
counter.fetch_add(1, Ordering::SeqCst);
t.join().unwrap();
assert_eq!(counter.load(Ordering::SeqCst), 2);
});
}
```
**When to use Loom:** Lock-free data structures, custom synchronization primitives, code using `Ordering` weaker than `SeqCst`. Regular `#[tokio::test]` is sufficient for high-level async workflows.
**Flag when:**
- Lock-free or atomic-based concurrency code has only regular tests
- Loom tests use `std::sync` instead of `loom::sync` (defeats the purpose)
## Benchmarking Rigor
### criterion with black_box
```rust
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn bench_parse(c: &mut Criterion) {
let input = "https://example.com/path?query=value";
c.bench_function("url_parse", |b| {
b.iter(|| url::Url::parse(black_box(input)))
});
}
criterion_group!(benches, bench_parse);
criterion_main!(benches);
```
Without `black_box`, the compiler may eliminate the entire computation as dead code. Use `black_box` on mutable pointer (`as_ptr()`) rather than shared reference -- the compiler can legally assume shared references are not mutated.
**Flag when:**
- Benchmarks do not use `black_box` on inputs or outputs
- Benchmark loop body includes I/O (`println!`, logging) or RNG that dominates measured time
- Benchmarks run once instead of using criterion's statistical sampling
- No `harness = false` in `Cargo.toml` for criterion benchmark targets
## compile_fail Tests
Verify code correctly fails to compile. Useful for type-level safety guarantees (Send, Sync, lifetimes).
**Doctests:** `compile_fail` attribute on doc code blocks. Crude -- passes for any compilation failure including typos.
**trybuild:** Fine-grained compile-fail testing. Each `.rs` file in `tests/ui/` has a matching `.stderr` with the expected error.
```rust
#[test]
fn compile_fail_tests() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
}
```
**Flag when:**
- `compile_fail` doctests lack a comment explaining which error is expected
- Crate enforces type-level invariants without compile_fail tests
- trybuild `.stderr` files are outdated after a rustc version bump
## Test Harness Customization
Set `harness = false` in `Cargo.toml` for custom test runners (fuzzers, model checkers, criterion benchmarks, WebAssembly targets). Without the harness, `#[test]` attributes are silently ignored -- you write your own `main`.
**Flag when:**
- `harness = false` set but test file still uses `#[test]` attributes
- Custom harness does not handle `--test-threads` or `--nocapture` when needed
## Mocking Strategies
**Trait-based (primary pattern):** Make code generic over traits, substitute mocks in tests. See [integration-tests.md](integration-tests.md) for async trait mock examples.
**Conditional compilation:** Use `#[cfg(test)]` to swap implementations when generics are inconvenient (e.g., deterministic timestamps, fixed randomness).
**mockall:** Generates mocks via `#[automock]`. Set `times()` constraints on expectations to catch unexpected call counts.
```rust
#[automock]
trait Storage {
fn get(&self, key: &str) -> Option<String>;
fn set(&self, key: &str, value: &str);
}
#[test]
fn cache_miss_fetches_from_source() {
let mut mock = MockStorage::new();
mock.expect_get().with(eq("key")).returning(|_| None);
mock.expect_set().with(eq("key"), eq("value")).times(1).return_const(());
let svc = Service::new(mock);
svc.fetch("key");
}
```
**Flag when:**
- Mocking external types directly instead of wrapping behind an owned trait
- `#[cfg(test)]` mocks change behavior that could mask production bugs
- mockall expectations lack `times()` constraints
## Review Rules Summary
| Pattern | Flag When |
|---------|-----------|
| Fuzzing | Parsers/deserializers lack fuzz targets; targets have no corpus |
| Property testing | Strategies too narrow; missing `prop_assert!` invariants |
| Miri | `unsafe` code not covered by `cargo miri test` in CI |
| Loom | Lock-free code tested only with regular `#[test]` |
| Benchmarks | Missing `black_box`; I/O in benchmark loop; no statistical sampling |
| compile_fail | No explanation of expected error; stale `.stderr` files |
| Custom harness | `#[test]` used alongside `harness = false` |
| Mocking | External types mocked directly; cfg(test) mocks skip validation |
FILE:references/integration-tests.md
# Integration Tests
## Test Directory Structure
```
project/
├── src/
│ └── lib.rs
└── tests/
├── common/
│ └── mod.rs # shared test utilities
├── api_test.rs # integration test suite
└── workflow_test.rs # integration test suite
```
Each file in `tests/` is compiled as a separate crate with access only to the public API.
## Shared Test Utilities
```rust
// tests/common/mod.rs
use my_crate::Config;
pub fn test_config() -> Config {
Config {
database_url: std::env::var("TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgres://localhost:5433/test".into()),
..Config::default()
}
}
pub async fn setup_test_db(pool: &PgPool) {
sqlx::query("TRUNCATE users, orders CASCADE")
.execute(pool)
.await
.expect("failed to clean test database");
}
```
```rust
// tests/api_test.rs
mod common;
#[tokio::test]
async fn test_create_user_returns_201() {
let config = common::test_config();
// ...
}
```
## Async Integration Tests
```rust
#[tokio::test]
async fn test_event_bus_delivers_to_all_subscribers() {
let (tx, _) = broadcast::channel(100);
let mut rx1 = tx.subscribe();
let mut rx2 = tx.subscribe();
tx.send(Event::new("test")).unwrap();
let event1 = rx1.recv().await.unwrap();
let event2 = rx2.recv().await.unwrap();
assert_eq!(event1.name, "test");
assert_eq!(event2.name, "test");
}
```
### Multi-Threaded Tests
When testing concurrent behavior, use `#[tokio::test(flavor = "multi_thread")]`:
```rust
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_concurrent_state_updates() {
let state = Arc::new(Mutex::new(Vec::new()));
let mut handles = Vec::new();
for i in 0..10 {
let state = Arc::clone(&state);
handles.push(tokio::spawn(async move {
let mut guard = state.lock().await;
guard.push(i);
}));
}
for handle in handles {
handle.await.unwrap();
}
let guard = state.lock().await;
assert_eq!(guard.len(), 10);
}
```
## Database Integration Tests
### Test Isolation
Each test should start with a clean state. Options:
1. **Truncate tables** — Fast, works for most cases
2. **Transaction rollback** — Test runs inside a transaction that's rolled back
3. **Separate database per test** — Most isolated, slowest
```rust
// Transaction rollback pattern
#[tokio::test]
async fn test_insert_user() {
let pool = PgPool::connect(&test_database_url()).await.unwrap();
let mut tx = pool.begin().await.unwrap();
let user = sqlx::query_as!(User, "INSERT INTO users (name) VALUES ($1) RETURNING *", "Test")
.fetch_one(&mut *tx)
.await
.unwrap();
assert_eq!(user.name, "Test");
// tx dropped here — rolls back automatically
}
```
### sqlx::test Macro
The `#[sqlx::test]` macro simplifies database test setup by automatically creating a fresh test database, running migrations, and cleaning up after each test. The connection pool is injected as a function argument.
```rust
#[sqlx::test]
async fn test_create_user(pool: PgPool) {
// pool is a fresh database with migrations applied
let result = sqlx::query!("INSERT INTO users (name) VALUES ($1) RETURNING id", "test")
.fetch_one(&pool)
.await
.unwrap();
assert!(result.id > 0);
}
```
Use `migrations` to specify a custom migrations path, and `fixtures` to load SQL fixture files from `tests/fixtures/`:
```rust
#[sqlx::test(migrations = "db/migrations")]
async fn test_with_custom_migrations(pool: PgPool) {
// uses migrations from db/migrations/ instead of the default
}
#[sqlx::test(fixtures("users", "orders"))]
async fn test_with_fixtures(pool: PgPool) {
// tests/fixtures/users.sql and tests/fixtures/orders.sql are loaded
let count = sqlx::query_scalar!("SELECT COUNT(*) FROM users")
.fetch_one(&pool)
.await
.unwrap();
assert!(count.unwrap() > 0);
}
```
Prefer `#[sqlx::test]` over manual pool setup with `#[tokio::test]` for database tests — it eliminates boilerplate and guarantees test isolation without manual truncation or transaction rollback.
## Mocking with Traits
Define traits as seams for testing. Implement mock versions for tests.
Since Rust 1.75, `async fn` works directly in trait definitions without the `async-trait` crate. Prefer native syntax for new code.
```rust
// BAD (edition 2024) - unnecessary async-trait dependency
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find(&self, id: Uuid) -> Result<Option<User>>;
async fn create(&self, input: CreateUser) -> Result<User>;
}
// GOOD (edition 2024) - native async fn in traits
pub trait UserRepository: Send + Sync {
fn find(&self, id: Uuid) -> impl Future<Output = Result<Option<User>>> + Send;
fn create(&self, input: CreateUser) -> impl Future<Output = Result<User>> + Send;
}
// Also valid - async fn directly (simpler, but caller can't name the future type)
pub trait UserRepository: Send + Sync {
async fn find(&self, id: Uuid) -> Result<Option<User>>;
async fn create(&self, input: CreateUser) -> Result<User>;
}
```
Production and mock implementations:
```rust
// Production implementation
pub struct PgUserRepository { pool: PgPool }
impl UserRepository for PgUserRepository {
async fn find(&self, id: Uuid) -> Result<Option<User>> {
sqlx::query_as!(User, "SELECT ... WHERE id = $1", id)
.fetch_optional(&self.pool)
.await
.map_err(Into::into)
}
// ...
}
// Test implementation
struct MockUserRepository {
users: Vec<User>,
}
impl UserRepository for MockUserRepository {
async fn find(&self, id: Uuid) -> Result<Option<User>> {
Ok(self.users.iter().find(|u| u.id == id).cloned())
}
// ...
}
```
**When `async-trait` is still needed:** Native `async fn` in traits does not support `dyn Trait` dispatch. If your code requires `Box<dyn UserRepository>`, keep using `async-trait` for that trait. See `beagle-rust:tokio-async-code-review` for async trait patterns in detail.
## `if let` Temporary Scope in Test Assertions (Edition 2024)
In edition 2024, temporaries in `if let` conditions are dropped at the end of the **condition**, not at the end of the block. This affects test patterns that inline method calls in `if let` conditions.
```rust
// BAD (edition 2024) - temporary lock guard drops after condition evaluates
// val may be a dangling reference inside the block
#[tokio::test]
async fn test_cache_hit() {
let cache = setup_cache().await;
if let Some(val) = cache.lock().await.get("key") {
assert_eq!(val, "expected"); // guard already dropped!
}
}
// GOOD (edition 2024) - bind the guard to extend its lifetime
#[tokio::test]
async fn test_cache_hit() {
let cache = setup_cache().await;
let guard = cache.lock().await;
if let Some(val) = guard.get("key") {
assert_eq!(val, "expected"); // guard lives through the block
}
}
```
This also affects non-async patterns with `RefCell`, `Mutex`, or any method returning a temporary with borrowed data:
```rust
// BAD (edition 2024) - RefCell borrow drops after condition
if let Some(item) = state.borrow().items.first() {
assert_eq!(item.name, "test"); // borrow already dropped
}
// GOOD - bind the borrow
let borrowed = state.borrow();
if let Some(item) = borrowed.items.first() {
assert_eq!(item.name, "test");
}
```
See `beagle-rust:tokio-async-code-review` for more `if let` temporary scope patterns with async lock guards.
## Test Configuration
Use environment variables or test-specific config files:
```rust
fn test_database_url() -> String {
std::env::var("TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5433/test".into())
}
```
For structured logging in tests:
```rust
// Initialize tracing subscriber for test output
use tracing_subscriber::fmt;
#[tokio::test]
async fn test_with_logging() {
let _ = fmt::try_init(); // ignore error if already initialized
tracing::info!("test starting");
// ...
}
```
## Review Questions
1. Are integration tests in the `tests/` directory?
2. Is shared test setup extracted to a `common` module?
3. Are database tests isolated (no cross-test contamination)?
4. Are traits used as seams for dependency injection in tests?
5. Is `#[tokio::test]` used for async tests?
6. Are multi-threaded tests using `flavor = "multi_thread"`?
7. Are database tests using `#[sqlx::test]` instead of manual pool setup?
8. Are mock traits using native `async fn` instead of `async-trait` where possible?
9. Do `if let` assertions with inline temporaries (lock guards, borrows) account for edition 2024 temporary scoping?
10. Is `#[expect]` used instead of `#[allow]` for test-specific lint suppressions?
FILE:references/unit-tests.md
# Unit Tests
## Standard Structure
```rust
// In src/types.rs
pub enum Status {
Active,
Inactive,
}
impl Status {
pub fn is_active(&self) -> bool {
matches!(self, Self::Active)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_status_active_returns_true() {
assert!(Status::Active.is_active());
}
#[test]
fn test_status_inactive_returns_false() {
assert!(!Status::Inactive.is_active());
}
}
```
## Assertion Patterns
### Value Comparisons
```rust
// BAD - error message is just "assertion failed"
assert!(result == 42);
// GOOD - shows left and right values on failure
assert_eq!(result, 42);
assert_ne!(result, 0);
// With context
assert_eq!(result, 42, "expected 42 for input {input}");
```
### Enum Variant Checking
```rust
// BAD - verbose pattern matching
match result {
Err(Error::NotFound(_)) => (),
other => panic!("expected NotFound, got {other:?}"),
}
// GOOD - matches! macro
assert!(matches!(result, Err(Error::NotFound(_))));
// With message
assert!(
matches!(result, Err(Error::NotFound(id)) if id == expected_id),
"expected NotFound for {expected_id}, got {result:?}"
);
```
### Result Testing
```rust
// Return Result from test for cleaner error propagation
#[test]
fn test_parse_valid_input() -> Result<(), Error> {
let config = parse("valid input")?;
assert_eq!(config.name, "expected");
Ok(())
}
// Test error cases
#[test]
fn test_parse_empty_input_returns_error() {
let result = parse("");
assert!(matches!(result, Err(Error::Empty)));
}
```
### Should Panic
Use sparingly. Prefer `Result`-returning tests.
```rust
// ACCEPTABLE - when testing an intentional panic
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_invalid_index_panics() {
let list = FixedList::new(5);
list.get(10); // should panic
}
```
## Test Helpers
Extract common setup into helper functions. Mark them with `#[expect(dead_code)]` (edition 2024) or `#[allow(dead_code)]` if not all tests use them.
```rust
#[cfg(test)]
mod tests {
use super::*;
fn sample_user() -> User {
User {
id: Uuid::nil(),
name: "Test User".into(),
email: "[email protected]".into(),
}
}
fn sample_config() -> Config {
Config {
port: 8080,
host: "localhost".into(),
..Config::default()
}
}
}
```
## Send + Sync Verification
Verify that types satisfy thread-safety bounds at compile time:
```rust
#[test]
fn assert_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Error>();
assert_send_sync::<WorkflowError>();
}
```
## Serialization Round-Trip Tests
```rust
#[test]
fn test_status_serialization_round_trip() {
let original = Status::InProgress;
let json = serde_json::to_string(&original).unwrap();
let deserialized: Status = serde_json::from_str(&json).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_status_serializes_to_expected_string() {
let status = Status::InProgress;
let s = serde_json::to_string(&status).unwrap();
assert_eq!(s, r#""in_progress""#);
}
```
## Test Naming Convention
Nested modules make test output readable and allow running groups:
```rust
#[cfg(test)]
mod tests {
use super::*;
mod parse_config {
use super::*;
#[test]
fn returns_config_when_valid_toml() {
let config = parse_config(VALID_TOML).unwrap();
assert_eq!(config.port, 8080);
}
#[test]
fn returns_error_when_empty_input() {
let err = parse_config("").unwrap_err();
assert!(matches!(err, ParseError::Empty));
}
#[test]
fn returns_error_when_missing_required_field() {
let err = parse_config("[server]").unwrap_err();
assert!(matches!(err, ParseError::MissingField(_)));
}
}
}
```
Output: `tests::parse_config::returns_config_when_valid_toml`, etc.
## One Assertion Per Test
Each test should verify one behavior. This makes failures easier to diagnose:
```rust
// BAD - which assertion failed?
#[test]
fn test_valid_inputs() {
assert!(parse("a").is_ok());
assert!(parse("ab").is_ok());
assert!(parse("abc").is_ok());
}
// GOOD - descriptive separate tests, or use rstest
#[rstest]
#[case::single_char("a")]
#[case::two_chars("ab")]
#[case::three_chars("abc")]
fn parse_accepts_valid_strings(#[case] input: &str) {
assert!(parse(input).is_ok(), "parse failed for: {input}");
}
```
## Snapshot Testing with `cargo insta`
Snapshot testing compares output against a saved "golden" version. On future runs, the test fails if output changes unless explicitly approved.
### Setup
```toml
# Cargo.toml
[dev-dependencies]
insta = { version = "1", features = ["yaml"] }
```
Install the CLI for better review workflow: `cargo install cargo-insta`
### Assert Macros
```rust
use insta::{assert_snapshot, assert_yaml_snapshot, assert_json_snapshot};
// Plain text snapshots
#[test]
fn test_error_display() {
let err = MyError::NotFound("user-123".into());
assert_snapshot!("error_not_found", err.to_string());
}
// YAML snapshots (best for version control diffs)
#[test]
fn test_config_serialization() {
let config = Config::default();
assert_yaml_snapshot!("default_config", config);
}
// JSON snapshots with redactions for unstable fields
#[test]
fn test_user_response() {
let user = create_test_user();
assert_json_snapshot!(user, {
".created_at" => "[timestamp]",
".id" => "[uuid]"
});
}
```
### Review Workflow
1. Write test with `assert_snapshot!` / `assert_yaml_snapshot!` / `assert_json_snapshot!`
2. Run `cargo insta test` — creates pending snapshots
3. Run `cargo insta review` — interactively accept or reject changes
4. Commit the `.snap` files in `snapshots/` alongside your tests
### When to Use Snapshots
- Serialized output (JSON, YAML, TOML)
- Error message formatting (`Display` impls)
- CLI output, rendered HTML, generated code
- Complex nested structures where `assert_eq!` is unwieldy
### When NOT to Use Snapshots
- Simple values — use `assert_eq!(x, 42)` instead
- Critical path logic — precise unit tests catch regressions faster
- Flaky/random output — use redactions or avoid snapshots entirely
- Huge objects — keep snapshots small and focused for easier review
## Parametrized Testing with `rstest`
`rstest` eliminates duplicated test functions when testing the same behavior with different inputs.
### Setup
```toml
# Cargo.toml
[dev-dependencies]
rstest = "0.23"
```
### Basic Parametrized Tests
```rust
use rstest::rstest;
#[rstest]
#[case::empty("", true)]
#[case::whitespace(" ", true)]
#[case::content("hello", false)]
fn is_blank_returns_expected(#[case] input: &str, #[case] expected: bool) {
assert_eq!(is_blank(input), expected);
}
```
Each `#[case]` generates a separate test with a descriptive name: `is_blank_returns_expected::empty`, etc.
### Fixtures
Share setup logic across tests with `#[fixture]`:
```rust
use rstest::{fixture, rstest};
#[fixture]
fn test_db() -> TestDb {
TestDb::new("sqlite::memory:")
}
#[rstest]
fn insert_user_succeeds(test_db: TestDb) {
let user = User::new("Alice");
assert!(test_db.insert(&user).is_ok());
}
#[rstest]
fn query_missing_user_returns_none(test_db: TestDb) {
assert!(test_db.find_user("nonexistent").is_none());
}
```
### Async Parametrized Tests
Combine `rstest` with `tokio::test`:
```rust
#[rstest]
#[case::valid_url("https://example.com", true)]
#[case::invalid_url("not-a-url", false)]
#[tokio::test]
async fn fetch_url_validates(#[case] url: &str, #[case] should_succeed: bool) {
let result = fetch(url).await;
assert_eq!(result.is_ok(), should_succeed);
}
```
### Considerations
- Descriptive case names are important — `#[case::empty_input("")]` beats `#[case("")]`
- It is harder for IDEs to run/locate specific parametrized tests
- For complex per-case setup, separate test functions may be clearer
## Doc Tests
Public API examples that double as tests:
```rust
/// Adds two numbers together.
///
/// # Examples
///
/// ```rust
/// # use my_crate::add;
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
```
Doc test attributes: `ignore`, `should_panic`, `no_run`, `compile_fail`.
Note: `cargo test --doc` runs doc tests. `cargo nextest` does NOT — run separately.
## Testing Error Messages
When errors don't implement `PartialEq`, test via `Display`:
```rust
#[test]
fn divide_by_zero_error_message() {
let err = divide(10.0, 0.0).unwrap_err();
assert_eq!(err.to_string(), "division by zero");
}
```
## `#[expect]` for Test Lint Suppression (Stable Since 1.81)
`#[expect(lint)]` is a self-cleaning alternative to `#[allow(lint)]`. The compiler warns when the suppressed lint no longer triggers, preventing stale suppressions from accumulating in test code.
```rust
// BAD - stale suppression goes undetected forever
#[allow(unused_variables)]
#[test]
fn test_complex_setup() {
let db = setup_db();
let _cache = setup_cache(); // if _cache is later removed, #[allow] stays silently
assert!(db.is_connected());
}
// GOOD - compiler warns when suppression is no longer needed
#[expect(unused_variables, reason = "cache setup needed for side effects")]
#[test]
fn test_complex_setup() {
let db = setup_db();
let _cache = setup_cache();
assert!(db.is_connected());
}
```
Common test-specific suppressions to migrate:
| `#[allow(...)]` | `#[expect(...)]` | When to use |
|-----------------|------------------|-------------|
| `#[allow(dead_code)]` | `#[expect(dead_code)]` | Test helpers not used by every test |
| `#[allow(unused_variables)]` | `#[expect(unused_variables)]` | Setup vars kept for side effects |
| `#[allow(clippy::needless_return)]` | `#[expect(clippy::needless_return)]` | Explicit returns for test clarity |
## `LazyLock` for Test Fixtures (Stable Since 1.80)
`std::sync::LazyLock` replaces `lazy_static!` and `once_cell::sync::Lazy` for shared test fixtures that are expensive to construct. Thread-safe by default.
```rust
// BAD - external dependency for test fixture
use lazy_static::lazy_static;
lazy_static! {
static ref TEST_CONFIG: Config = Config::load("test.toml").unwrap();
}
// BAD - also external dependency
use once_cell::sync::Lazy;
static TEST_CONFIG: Lazy<Config> = Lazy::new(|| Config::load("test.toml").unwrap());
// GOOD (edition 2024) - std library, no external crate
use std::sync::LazyLock;
static TEST_CONFIG: LazyLock<Config> = LazyLock::new(|| Config::load("test.toml").unwrap());
```
For test fixtures that don't need to cross thread boundaries, use `std::cell::LazyCell` instead.
**Note:** `tokio::sync::OnceCell` is still preferred when fixture initialization requires `.await`.
## Tail Expression Temporary Scope (Edition 2024)
In edition 2024, temporaries in tail expressions are dropped **before** local variables. This can affect test functions that return `Result` and create temporaries in the return expression.
```rust
// Edition 2021 - temporaries in tail expression outlive locals
#[test]
fn test_parse_config() -> Result<(), Error> {
let input = "key=value";
// temporary String from to_string() lives until end of function
Ok(parse(input.to_string().as_str())?)
}
// Edition 2024 - temporary String drops BEFORE the function returns
// This may cause "temporary value dropped while borrowed" errors
// Fix: bind the temporary to a local variable
#[test]
fn test_parse_config() -> Result<(), Error> {
let input = "key=value";
let owned = input.to_string();
Ok(parse(owned.as_str())?)
}
```
This primarily affects tests that chain method calls in the return position. If the compiler reports "temporary value dropped while borrowed" after an edition migration, bind the temporary to a `let` binding.
## Review Questions
1. Are unit tests in `#[cfg(test)]` modules within source files?
2. Do assertions use `assert_eq!` for value comparisons?
3. Are error variants checked specifically (not just "is error")?
4. Are test helpers extracted for repeated setup?
5. Do types that cross thread boundaries have Send/Sync tests?
6. Do serialized types have round-trip tests?
7. Are tests named descriptively (not `test_happy_path`)?
8. Do tests verify one behavior each?
9. Is snapshot testing used for complex structural output?
10. Do public API functions have doc test examples?
11. Is `#[expect]` used instead of `#[allow]` for test-specific lint suppressions?
12. Are `lazy_static!` / `once_cell` test fixtures replaced with `std::sync::LazyLock` when MSRV allows?
13. Do tail expression temporaries in `Result`-returning tests avoid dangling borrows under edition 2024?
Reviews Rust code for ownership, borrowing, lifetime, error handling, trait design, unsafe usage, and common mistakes. Use when reviewing .rs files, checking...
---
name: rust-code-review
description: Reviews Rust code for ownership, borrowing, lifetime, error handling, trait design, unsafe usage, and common mistakes. Use when reviewing .rs files, checking borrow checker issues, error handling patterns, or trait implementations. Covers Rust 2024 edition patterns and modern idioms.
---
# Rust Code Review
## Review Workflow
Follow this sequence to avoid false positives and catch edition-specific issues:
1. **Check `Cargo.toml`** — Note the Rust edition (2018, 2021, 2024) and MSRV if set. Edition 2024 introduces breaking changes to unsafe semantics, RPIT lifetime capture, temporary scoping, and `!` type fallback. This determines which patterns apply. Check workspace structure if present.
2. **Check dependencies** — Note key crates (thiserror vs anyhow, tokio features, serde features). These inform which patterns are expected.
3. **Scan changed files** — Read full functions, not just diffs. Many Rust bugs hide in ownership flow across a function.
4. **Check each category** — Work through the checklist below, loading references as needed.
5. **Verify before reporting** — Complete **Gates** (below), including the verification-protocol gate, before submitting findings.
## Gates
These steps are **sequenced**: do not skip ahead with “mental verification.” Each step has an objective **Pass** you can satisfy from files on disk and your own read path.
1. **Crate context** — Before relying on edition-specific checklist rows (Edition 2024, MSRV-sensitive APIs) or dependency assumptions. **Pass:** You opened the relevant `Cargo.toml` (package or workspace manifest) and can state `edition` and `rust-version` (if set) in one line.
2. **Expanded read** — Before reporting a **Major** or **Critical** finding. **Pass:** You read the full function, `unsafe` block, or `impl` / trait item that contains the cited line (not only a diff hunk).
3. **Severity match** — Before each finding line in the report. **Pass:** The **Severity** label matches **Severity Calibration** for that issue class, or you use **Informational** and give a one-line rationale.
4. **Verification protocol** — Before finalizing the report. **Pass:** `beagle-rust:review-verification-protocol` is loaded and every step in it that applies to this review is completed (do not substitute a vague “I checked”).
## Output Format
Report findings as:
```text
[FILE:LINE] ISSUE_TITLE
Severity: Critical | Major | Minor | Informational
Description of the issue and why it matters.
```
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Ownership transfers, borrowing, lifetimes, clone traps, iterators | [references/ownership-borrowing.md](references/ownership-borrowing.md) |
| Lifetime variance, covariance/invariance, memory regions | [references/lifetime-variance.md](references/lifetime-variance.md) |
| Result/Option handling, thiserror, anyhow, error context, Error trait | [references/error-handling.md](references/error-handling.md) |
| Async pitfalls, Send/Sync bounds, runtime blocking | [references/async-concurrency.md](references/async-concurrency.md) |
| Send/Sync semantics, atomics, memory ordering, lock patterns | [references/concurrency-primitives.md](references/concurrency-primitives.md) |
| Type layout, alignment, repr, PhantomData, generics vs dyn Trait | [references/types-layout.md](references/types-layout.md) |
| Unsafe code, API design, derive patterns, clippy patterns | [references/common-mistakes.md](references/common-mistakes.md) |
| Safety contracts, raw pointers, MaybeUninit, soundness, Miri | [references/unsafe-deep.md](references/unsafe-deep.md) |
> For development guidance on performance, pointer types, type state, clippy config, iterators, generics, and documentation, use the `beagle-rust:rust-best-practices` skill.
## Review Checklist
### Ownership and Borrowing
- [ ] No unnecessary `.clone()` to silence the borrow checker (hiding design issues)
- [ ] No `.clone()` inside loops — prefer `.cloned()` or `.copied()` on iterators
- [ ] No cloning to avoid lifetime annotations (take ownership explicitly or restructure)
- [ ] References have appropriate lifetimes (not overly broad `'static` when shorter lifetime works)
- [ ] **Edition 2024**: RPIT (`-> impl Trait`) captures all in-scope lifetimes by default; use `+ use<'a>` for precise capture control
- [ ] `&str` preferred over `String`, `&[T]` over `Vec<T>` in function parameters
- [ ] `impl AsRef<T>` or `Into<T>` used for flexible API parameters
- [ ] No dangling references or use-after-move
- [ ] Interior mutability (`Cell`, `RefCell`, `Mutex`) used only when shared mutation is genuinely needed
- [ ] Small types (≤24 bytes) derive `Copy` and are passed by value
- [ ] `Cow<'_, T>` used when ownership is ambiguous
- [ ] Iterator chains preferred over index-based loops for collection transforms
- [ ] No premature `.collect()` — pass iterators directly when the consumer accepts them
- [ ] `.sum()` preferred over `.fold()` for summation (compiler optimizes better)
- [ ] `_or_else` variants used when fallbacks involve allocation
- [ ] **Edition 2024**: `if let` temporaries drop at end of the `if let` — code relying on temporaries living through the else branch needs restructuring
- [ ] **Edition 2024**: `Box<[T]>` implements `IntoIterator` — prefer direct iteration over `into_vec()` first
### Error Handling
- [ ] `Result<T, E>` used for recoverable errors, not `panic!`/`unwrap`/`expect`
- [ ] Error types provide context (thiserror with `#[error("...")]` or manual `Display`)
- [ ] `?` operator used with proper `From` implementations or `.map_err()`
- [ ] `unwrap()` / `expect()` only in tests, examples, or provably-safe contexts
- [ ] Error variants are specific enough to be actionable by callers
- [ ] `anyhow` used in applications, `thiserror` in libraries (or clear rationale for alternatives)
- [ ] `_or_else` variants used when fallbacks involve allocation (`ok_or_else`, `unwrap_or_else`)
- [ ] `let-else` used for early returns on failure (`let Ok(x) = expr else { return ... }`)
- [ ] `inspect_err` used for error logging, `map_err` for error transformation
### Traits and Types
- [ ] Traits are minimal and cohesive (single responsibility)
- [ ] `derive` macros appropriate for the type (`Clone`, `Debug`, `PartialEq` used correctly)
- [ ] Newtypes used to prevent primitive obsession (e.g., `struct UserId(Uuid)` not bare `Uuid`)
- [ ] `From`/`Into` implementations are lossless and infallible; `TryFrom` for fallible conversions
- [ ] Sealed traits used when external implementations shouldn't be allowed
- [ ] Default implementations provided where they make sense
- [ ] `Send + Sync` bounds verified for types shared across threads
- [ ] `#[diagnostic::on_unimplemented]` used on public traits to provide clear error messages when users forget to implement them
### Unsafe Code
- [ ] `unsafe` blocks have safety comments explaining invariants
- [ ] `unsafe` is minimal — only the truly unsafe operation is inside the block
- [ ] Safety invariants are documented and upheld by surrounding safe code
- [ ] No undefined behavior (null pointer deref, data races, invalid memory access)
- [ ] `unsafe` trait implementations justify why the contract is upheld
- [ ] **Edition 2024**: `unsafe fn` bodies use explicit `unsafe {}` blocks around unsafe ops (`unsafe_op_in_unsafe_fn` is deny)
- [ ] **Edition 2024**: `extern "C" {}` blocks written as `unsafe extern "C" {}`
- [ ] **Edition 2024**: `#[no_mangle]` and `#[export_name]` written as `#[unsafe(no_mangle)]` and `#[unsafe(export_name)]`
### Naming and Style
- [ ] Types are `PascalCase`, functions/methods `snake_case`, constants `SCREAMING_SNAKE_CASE`
- [ ] Modules use `snake_case`
- [ ] `is_`, `has_`, `can_` prefixes for boolean-returning methods
- [ ] Builder pattern methods take and return `self` (not `&mut self`) for chaining
- [ ] Public items have doc comments (`///`)
- [ ] `#[must_use]` on functions where ignoring the return value is likely a bug
- [ ] Imports ordered: std → external crates → workspace → crate/super
- [ ] `#[expect(clippy::...)]` preferred over `#[allow(...)]` for lint suppression
### Performance
> Detailed guidance: `beagle-rust:rust-best-practices` skill (references/performance.md)
- [ ] No unnecessary allocations in hot paths (prefer `&str` over `String`, `&[T]` over `Vec<T>`)
- [ ] `collect()` type is specified or inferable
- [ ] Iterators preferred over indexed loops for collection transforms
- [ ] `Vec::with_capacity()` used when size is known
- [ ] No redundant `.to_string()` / `.to_owned()` chains
- [ ] No intermediate `.collect()` when passing iterators directly works
- [ ] `.sum()` preferred over `.fold()` for summation
- [ ] Static dispatch (`impl Trait`) used over dynamic (`dyn Trait`) unless flexibility required
### Clippy Configuration
> Detailed guidance: `beagle-rust:rust-best-practices` skill (references/clippy-config.md)
- [ ] Workspace-level lints configured in `Cargo.toml` (`[workspace.lints.clippy]` or `[lints.clippy]`)
- [ ] `#[expect(clippy::lint)]` used over `#[allow(...)]` — warns when suppression becomes stale
- [ ] Justification comment present when suppressing any lint
- [ ] Key lints enforced: `redundant_clone`, `large_enum_variant`, `needless_collect`, `perf` group
- [ ] `cargo clippy --all-targets --all-features -- -D warnings` passes
- [ ] Doc lints enabled for library crates (`missing_docs`, `broken_intra_doc_links`)
### Type State Pattern
> Detailed guidance: `beagle-rust:rust-best-practices` skill (references/type-state-pattern.md)
- [ ] `PhantomData<State>` used for zero-cost compile-time state machines (not runtime enums/booleans)
- [ ] State transitions consume `self` and return new state type (prevents reuse of old state)
- [ ] Only applicable methods available per state (invalid operations are compile errors)
- [ ] Pattern used where it adds safety value (builders with required fields, connection states, workflows)
- [ ] Not overused for trivial state (simple enums are fine when runtime flexibility needed)
## Severity Calibration
### Critical (Block Merge)
- `unsafe` code with unsound invariants or undefined behavior
- Use-after-free or dangling reference patterns
- `unwrap()` on user input or external data in production code
- Data races (concurrent mutation without synchronization)
- Memory leaks via circular `Arc<Mutex<...>>` without weak references
### Major (Should Fix)
- Errors returned without context (bare `return err` equivalent)
- `.clone()` masking ownership design issues in hot paths
- Missing `Send`/`Sync` bounds on types used across threads
- `panic!` for recoverable errors in library code
- Overly broad `'static` lifetimes hiding API design issues
### Minor (Consider Fixing)
- Missing doc comments on public items
- `String` parameter where `&str` or `impl AsRef<str>` would work
- Derive macros missing for types that should have them
- Unused feature flags in `Cargo.toml`
- Suboptimal iterator chains (multiple allocations where one suffices)
### Informational (Note Only)
- Suggestions to introduce newtypes for domain modeling
- Refactoring ideas for trait design
- Performance optimizations without measured impact
- Suggestions to add `#[must_use]` or `#[non_exhaustive]`
## When to Load References
- Reviewing ownership, borrows, lifetimes, clone traps → ownership-borrowing.md
- Reviewing lifetime variance, covariance/invariance, multiple lifetime params → lifetime-variance.md
- Reviewing Result/Option handling, error types, Error trait impls → error-handling.md
- Reviewing async code, tokio usage, task management → async-concurrency.md
- Reviewing Send/Sync, atomics, memory ordering, mutexes, lock patterns → concurrency-primitives.md
- Reviewing type layout, alignment, repr, PhantomData, generics vs dyn → types-layout.md
- Reviewing unsafe code, API design, derive macros, clippy patterns → common-mistakes.md
- Reviewing safety contracts, raw pointers, MaybeUninit, soundness → unsafe-deep.md
- Reviewing performance, pointer types, type state, generics, iterators, documentation → `beagle-rust:rust-best-practices` skill
## Valid Patterns (Do NOT Flag)
These are acceptable Rust patterns — reporting them wastes developer time:
- **`.clone()` in tests** — Clarity over performance in test code
- **`unwrap()` in tests and examples** — Acceptable where panicking on failure is intentional
- **`Box<dyn Error>` in simple binaries** — Not every application needs custom error types
- **`String` fields in structs** — Owned data in structs is correct; `&str` fields require lifetime parameters
- **`#[allow(dead_code)]` during development** — Common during iteration
- **`todo!()` / `unimplemented!()` in new code** — Valid placeholder during active development
- **`.expect("reason")` with clear message** — Self-documenting and acceptable for invariants
- **`use super::*` in test modules** — Standard pattern for `#[cfg(test)]` modules
- **Type aliases for complex types** — `type Result<T> = std::result::Result<T, MyError>` is idiomatic
- **`impl Trait` in return position** — Zero-cost abstraction, standard pattern
- **Turbofish syntax** — `collect::<Vec<_>>()` is idiomatic when type inference needs help
- **`_` prefix for intentionally unused variables** — Compiler convention
- **`#[expect(clippy::...)]` with justification** — Self-cleaning lint suppression
- **`Arc::clone(&arc)`** — Explicit Arc cloning is idiomatic and recommended
- **`std::sync::Mutex` for short critical sections in async** — Tokio docs recommend this
- **`for` loops over iterators** — When early exit or side effects are needed
- **`async fn` in trait definitions** — Stable since 1.75; `async-trait` crate only needed for `dyn Trait` or pre-1.75 MSRV
- **`LazyCell` / `LazyLock` from std** — Stable since 1.80; replaces `once_cell` and `lazy_static` for new code
- **`+ use<'a, T>` precise capture syntax** — Edition 2024 syntax for controlling RPIT lifetime capture
## Context-Sensitive Rules
Only flag these issues when the specific conditions apply:
| Issue | Flag ONLY IF |
|-------|--------------|
| Missing error context | Error crosses module boundary without context |
| Unnecessary `.clone()` | In hot path or repeated call, not test/setup code |
| Missing doc comments | Item is `pub` and not in a `#[cfg(test)]` module |
| `unwrap()` usage | In production code path, not test/example/provably-safe |
| Missing `Send + Sync` | Type is actually shared across thread/task boundaries |
| Overly broad lifetime | A shorter lifetime would work AND the API is public |
| Missing `#[must_use]` | Function returns a value that callers commonly ignore |
| Stale `#[allow]` suppression | Should be `#[expect]` for self-cleaning lint management |
| Missing `Copy` derive | Type is ≤24 bytes with all-Copy fields and used frequently |
| **Edition 2024**: `!` type fallback | Match on `Result<T, !>` or diverging expressions where `()` fallback was assumed — `!` now falls back to `!` not `()` |
| **Edition 2024**: `r#gen` identifier | Code uses `gen` as an identifier — must be `r#gen` in edition 2024 (reserved keyword) |
## Before Submitting Findings
Satisfy **Gates** § verification protocol (step 4). Load and follow `beagle-rust:review-verification-protocol` before reporting any issue.
FILE:references/async-concurrency.md
# Async and Concurrency
## Critical Anti-Patterns
### 1. Blocking in Async Context
Blocking operations inside async functions starve the tokio runtime's thread pool, causing latency spikes and potential deadlocks.
```rust
// BAD - blocks the async runtime thread
async fn read_config() -> Config {
let data = std::fs::read_to_string("config.toml").unwrap(); // BLOCKING!
toml::from_str(&data).unwrap()
}
// GOOD - use async I/O
async fn read_config() -> Result<Config, Error> {
let data = tokio::fs::read_to_string("config.toml").await?;
let config: Config = toml::from_str(&data)?;
Ok(config)
}
// GOOD - offload blocking work to a dedicated thread
async fn compute_hash(data: Vec<u8>) -> Result<Hash, Error> {
tokio::task::spawn_blocking(move || {
expensive_hash(&data)
}).await?
}
```
Common blockers to watch for: `std::fs`, `std::net`, `std::thread::sleep`, CPU-heavy computation, synchronous database drivers.
### 2. Holding Locks Across Await Points
A `MutexGuard` held across an `.await` can cause deadlocks and prevents `Send` bounds from being satisfied.
```rust
// BAD - guard held across await
async fn update(state: &Mutex<State>) {
let mut guard = state.lock().await;
let data = fetch_data().await; // guard still held!
guard.data = data;
}
// GOOD - drop guard before await
async fn update(state: &Mutex<State>) {
let current = {
let guard = state.lock().await;
guard.data.clone()
}; // guard dropped here
let new_data = fetch_data().await;
let mut guard = state.lock().await;
guard.data = new_data;
}
```
### 3. Using std::sync::Mutex in Async Code
`std::sync::Mutex` blocks the thread while waiting. In async code, use `tokio::sync::Mutex` which yields to the runtime, or use `std::sync::Mutex` only for short, non-async critical sections.
```rust
// RISKY - std mutex in async context
use std::sync::Mutex;
async fn process(shared: &Mutex<Vec<Item>>) {
let mut guard = shared.lock().unwrap(); // blocks thread
guard.push(item);
}
// GOOD - tokio mutex for async-aware locking
use tokio::sync::Mutex;
async fn process(shared: &Mutex<Vec<Item>>) {
let mut guard = shared.lock().await; // yields to runtime
guard.push(item);
}
```
Exception: `std::sync::Mutex` is fine when the critical section is very short (no async operations, just field access) because it avoids the overhead of tokio's async mutex. The tokio docs themselves recommend this pattern.
> For a detailed comparison of `tokio::sync::Mutex` vs `std::sync::Mutex` and other sync primitives (`RwLock`, `Semaphore`, `Notify`), see `beagle-rust:tokio-async-code-review` (references/sync-primitives.md).
### 4. Spawning Tasks Without Join Handles
Fire-and-forget tasks can silently fail, leak resources, or outlive their logical scope.
```rust
// BAD - task error is lost, no lifecycle management
tokio::spawn(async {
process_batch(items).await;
});
// GOOD - handle tracked for cancellation and error reporting
let handle = tokio::spawn(async move {
process_batch(items).await
});
// ... later
match handle.await {
Ok(result) => result?,
Err(e) => tracing::error!(error = %e, "batch processing panicked"),
}
```
### 5. Missing Cancellation Safety
When a future is dropped (e.g., via `tokio::select!`), partially completed operations may leave state inconsistent.
```rust
// RISKY - if timeout fires, partial write may have occurred
tokio::select! {
result = write_to_db(&data) => { ... }
_ = tokio::time::sleep(timeout) => {
return Err(Error::Timeout);
}
}
// SAFER - use cancellation-safe operations or checkpoints
tokio::select! {
result = write_to_db_atomic(&data) => { ... }
_ = tokio::time::sleep(timeout) => {
// write_to_db_atomic either completes fully or not at all
return Err(Error::Timeout);
}
}
```
### 6. Send/Sync Bound Violations
Types shared across tasks must be `Send`. Types shared across threads must be `Send + Sync`. `Rc`, `RefCell`, and raw pointers are not `Send`.
```rust
// WON'T COMPILE - Rc is not Send
let data = Rc::new(vec![1, 2, 3]);
tokio::spawn(async move {
println!("{:?}", data); // Rc is !Send
});
// GOOD - Arc is Send + Sync
let data = Arc::new(vec![1, 2, 3]);
tokio::spawn(async move {
println!("{:?}", data);
});
```
## `async fn` in Traits (Stable Since 1.75)
Native `async fn` in trait definitions is stable since Rust 1.75. The `async-trait` crate is no longer needed for most use cases.
```rust
// BAD — unnecessary dependency on async-trait (if MSRV >= 1.75)
#[async_trait::async_trait]
trait Service {
async fn call(&self, req: Request) -> Response;
}
// GOOD — native async fn in trait
trait Service {
async fn call(&self, req: Request) -> Response;
}
```
**When `async-trait` is still needed**:
- **`dyn Trait`**: Native async traits don't support dynamic dispatch (`dyn Service`). Use `async-trait` or the `trait_variant` crate for object-safe async traits.
- **MSRV < 1.75**: Projects that must compile on older Rust versions.
When reviewing, check whether `async-trait` usage can be replaced with native syntax. The crate adds a heap allocation per call (`Box::pin`), which native async traits avoid.
## Channel Patterns
Choose channels based on communication shape: `mpsc` for back-pressure, `broadcast` for fan-out, `oneshot` for request-response, `watch` for latest-value. Ensure bounded channels are sized to avoid OOM risks with unbounded alternatives.
> For detailed channel patterns, usage examples, and pitfalls, see `beagle-rust:tokio-async-code-review` (references/channels.md).
## Graceful Shutdown
Use `CancellationToken` from `tokio_util` with child tokens for hierarchical shutdown. Combine with `tokio::select!` to listen for cancellation alongside work.
> For full shutdown patterns and cancellation token usage, see `beagle-rust:tokio-async-code-review` (references/task-management.md).
## Review Questions
1. Are there any blocking operations (`std::fs`, `std::net`, `thread::sleep`) in async functions?
2. Are mutex guards dropped before `.await` points?
3. Is `tokio::sync::Mutex` used when locks are held across await points?
4. Are spawned tasks tracked via join handles?
5. Is `select!` used with cancellation-safe futures?
6. Do types shared across tasks satisfy `Send + Sync` bounds?
7. Can `async-trait` be replaced with native `async fn` in traits (MSRV >= 1.75, no `dyn Trait` needed)?
FILE:references/common-mistakes.md
# Unsafe Code, API Design, and Derive Patterns
> For performance, pointer types, clippy config, iterators, generics, and documentation guidance, see the `beagle-rust:rust-best-practices` skill.
## Unsafe Code
### Missing Safety Comments
Every `unsafe` block must explain why the invariants are upheld. This isn't a style preference — it's how future maintainers verify the code is correct.
```rust
// BAD - no justification
let value = unsafe { &*ptr };
// GOOD - documents the invariant
// SAFETY: `ptr` was allocated by `Box::into_raw` in `new()` and
// is guaranteed to be valid until `drop()` is called. We hold &self,
// which prevents concurrent mutation.
let value = unsafe { &*ptr };
```
### Unsafe Operations in `unsafe fn` (Edition 2024)
In edition 2024, `unsafe_op_in_unsafe_fn` is deny by default. Being inside an `unsafe fn` no longer implicitly permits unsafe operations — each one needs its own `unsafe {}` block with a safety comment.
```rust
// BAD in edition 2024 — unsafe ops without explicit blocks
unsafe fn process_raw(ptr: *const u8, len: usize) -> &[u8] {
std::slice::from_raw_parts(ptr, len) // ERROR: requires unsafe block
}
// GOOD — explicit unsafe block inside unsafe fn
unsafe fn process_raw(ptr: *const u8, len: usize) -> &[u8] {
// SAFETY: caller guarantees ptr is valid for len bytes and
// the resulting slice does not outlive the allocation.
unsafe { std::slice::from_raw_parts(ptr, len) }
}
```
This makes `unsafe fn` bodies auditable at the same granularity as regular functions. Every unsafe operation gets its own safety justification.
### `unsafe extern` Blocks (Edition 2024)
In edition 2024, `extern` blocks must be marked `unsafe` because declaring foreign functions is inherently unsafe (the compiler cannot verify the signatures are correct).
```rust
// BAD in edition 2024
extern "C" {
fn strlen(s: *const c_char) -> usize;
}
// GOOD in edition 2024
unsafe extern "C" {
fn strlen(s: *const c_char) -> usize;
}
```
### `unsafe` Attributes (Edition 2024)
Attributes that affect ABI or symbol names are now safety-sensitive and must be wrapped in `unsafe(...)`:
```rust
// BAD in edition 2024
#[no_mangle]
pub extern "C" fn my_func() {}
#[export_name = "custom_name"]
pub fn another_func() {}
// GOOD in edition 2024
#[unsafe(no_mangle)]
pub extern "C" fn my_func() {}
#[unsafe(export_name = "custom_name")]
pub fn another_func() {}
```
### Overly Broad Unsafe Blocks
Only the minimum necessary code should be inside `unsafe`. Surrounding safe code makes it harder to audit.
```rust
// BAD - safe operations inside unsafe block
unsafe {
let len = data.len(); // safe
let ptr = data.as_ptr(); // safe
std::slice::from_raw_parts(ptr, len) // this is the only unsafe part
}
// GOOD - narrow unsafe boundary
let len = data.len();
let ptr = data.as_ptr();
// SAFETY: ptr and len come from the same slice, which is still alive
unsafe { std::slice::from_raw_parts(ptr, len) }
```
## API Design
### Non-Exhaustive Enums
Public enums should be `#[non_exhaustive]` if variants may be added in the future. Without it, adding a variant is a breaking change.
```rust
// GOOD - allows adding variants without breaking downstream
#[derive(Debug)]
#[non_exhaustive]
pub enum Status {
Pending,
Active,
Complete,
}
```
### Builder Pattern
For types with many optional fields, builders prevent argument confusion and allow incremental construction.
```rust
// Builder takes ownership for chaining
pub struct ServerBuilder {
port: u16,
host: String,
workers: Option<usize>,
}
impl ServerBuilder {
pub fn new(port: u16) -> Self {
Self { port, host: "0.0.0.0".into(), workers: None }
}
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = host.into();
self
}
pub fn workers(mut self, n: usize) -> Self {
self.workers = Some(n);
self
}
pub fn build(self) -> Result<Server, Error> { ... }
}
```
## Clippy Patterns Worth Flagging
These are patterns that `clippy` warns about but are easy to miss during review:
- `manual_map` — match arms that just wrap in `Some`/`Ok`; use `.map()` instead
- `needless_borrow` — `&` on values that already implement the trait for references
- `redundant_closure` — closures that just call a function: `|x| foo(x)` -> `foo`
- `single_match` — `match` with one arm + wildcard; use `if let` instead
- `or_fun_call` — `.unwrap_or(Vec::new())` allocates even on the happy path; use `.unwrap_or_default()`
### `#[expect]` Over `#[allow]`
`#[expect(clippy::lint)]` warns when the suppression is no longer needed. `#[allow]` stays forever unnoticed:
```rust
// BAD - stale suppression goes undetected
#[allow(clippy::large_enum_variant)]
enum Message { /* ... */ }
// GOOD - compiler warns when lint no longer triggers
// Justification: Content variant intentionally large for fast matching
#[expect(clippy::large_enum_variant)]
enum Message { /* ... */ }
```
Always add a justification comment when suppressing lints.
## Derive Macro Guidelines
| Trait | Derive When |
|-------|-------------|
| `Debug` | Almost always — essential for logging and debugging |
| `Clone` | Type is used in contexts requiring copies (collections, Arc patterns) |
| `PartialEq, Eq` | Type is compared or used as HashMap/HashSet key |
| `Hash` | Type is used as HashMap/HashSet key (requires `Eq`) |
| `Default` | Type has a meaningful default state |
| `Serialize, Deserialize` | Type crosses serialization boundaries (API, DB, config) |
| `Send, Sync` | Auto-derived; manually implement ONLY with unsafe justification |
## `LazyCell` / `LazyLock` (Stable Since 1.80)
`std::cell::LazyCell` and `std::sync::LazyLock` replace the `once_cell` and `lazy_static` crates for lazy initialization. Prefer the std types in new code.
```rust
// BAD — external dependency for something std now provides
use once_cell::sync::Lazy;
static CONFIG: Lazy<Config> = Lazy::new(|| load_config());
// BAD — macro-based, no longer needed
lazy_static::lazy_static! {
static ref CONFIG: Config = load_config();
}
// GOOD — std::sync::LazyLock for thread-safe global lazy init
use std::sync::LazyLock;
static CONFIG: LazyLock<Config> = LazyLock::new(|| load_config());
// GOOD — std::cell::LazyCell for single-threaded lazy init
use std::cell::LazyCell;
let value: LazyCell<String> = LazyCell::new(|| expensive_compute());
```
**When to flag**: New code (or code with MSRV >= 1.80) using `once_cell` or `lazy_static` when `LazyCell`/`LazyLock` would work. Existing code using these crates is fine if the MSRV prevents migration.
## Review Questions
1. Does every `unsafe` block have a safety comment?
2. Are `unsafe` blocks as narrow as possible?
3. Are public enums `#[non_exhaustive]` if they may grow?
4. Are appropriate derive macros present for each type's usage?
5. Is `#[expect]` used instead of `#[allow]` for lint suppression?
6. Would clippy flag any of these patterns?
7. Are builders used for types with many optional fields?
8. **Edition 2024**: Do `unsafe fn` bodies use explicit `unsafe {}` blocks?
9. **Edition 2024**: Are `extern` blocks marked `unsafe extern`?
10. **Edition 2024**: Are `#[no_mangle]` / `#[export_name]` wrapped in `#[unsafe(...)]`?
11. Is `LazyLock`/`LazyCell` used instead of `once_cell`/`lazy_static` when MSRV allows?
FILE:references/concurrency-primitives.md
# Concurrency Primitives
For async-specific patterns (tokio, channels, cancellation), see [async-concurrency.md](async-concurrency.md).
## Send and Sync Semantics
- **`Send`**: a type can be transferred to another thread. Most types are `Send`. Notable exceptions: `Rc`, `MutexGuard` (on some platforms).
- **`Sync`**: a type can be shared (via `&T`) between threads. `T` is `Sync` if `&T` is `Send`. Notable exceptions: `Cell`, `RefCell`, `Rc`.
Both are auto-traits: the compiler implements them if all fields are `Send`/`Sync`. Raw pointers block auto-implementation as a safety guard.
### Manual Implementation
**Flag when**: `unsafe impl Send` or `unsafe impl Sync` appears without:
1. A safety comment explaining why the invariant holds
2. Bounds on generic parameters
```rust
// BAD — missing bound allows T: !Send to cross threads
unsafe impl<T> Send for MyWrapper<T> {}
// GOOD — bound ensures inner T is also Send
unsafe impl<T: Send> Send for MyWrapper<T> {}
```
**Check for**: types containing `Rc`, `Cell`, `RefCell`, or raw pointers that manually implement `Send`/`Sync` — these need extra scrutiny.
## Atomics and Memory Ordering
Atomic types (`AtomicBool`, `AtomicUsize`, `AtomicPtr`, etc.) provide lock-free concurrent access. Every operation takes an `Ordering` argument.
### Ordering Guide
| Ordering | Guarantees | Use When |
|----------|-----------|----------|
| `Relaxed` | Atomic access only, no ordering with other ops | Counters, statistics, flags where order doesn't matter |
| `Acquire` | Loads cannot be reordered before this load; sees all stores before a paired `Release` | Reading a lock state, reading a "ready" flag |
| `Release` | Stores cannot be reordered after this store | Writing to a lock state, setting a "ready" flag |
| `AcqRel` | Both `Acquire` and `Release` | `compare_exchange` that both reads and writes |
| `SeqCst` | All threads see the same total order of `SeqCst` operations | When multiple atomics must be globally ordered |
### Common Patterns
**Acquire/Release pair** (most common for synchronization):
```rust
// Writer thread
data.store(42, Ordering::Relaxed);
flag.store(true, Ordering::Release); // all prior stores visible to Acquire readers
// Reader thread
if flag.load(Ordering::Acquire) {
// guaranteed to see data == 42
let val = data.load(Ordering::Relaxed);
}
```
**Flag when**:
- `Relaxed` used where the value gates access to other shared data — needs at least `Acquire`/`Release`
- `SeqCst` used everywhere "to be safe" — this is correct but may be unnecessarily costly on non-x86 architectures. Flag as informational if `Acquire`/`Release` would suffice.
- `compare_exchange` success ordering is `Relaxed` when it guards a critical section — needs `AcqRel`
**Valid pattern**: `Relaxed` for metrics counters, reference counts (when paired with `Acquire` on the final decrement), and statistics.
## Mutex vs RwLock vs Atomics
| Primitive | Read Contention | Write Contention | Use When |
|-----------|----------------|------------------|----------|
| `Mutex` | Blocks all readers | Blocks all | Simple mutual exclusion, short critical sections |
| `RwLock` | Concurrent reads OK | Blocks all | Read-heavy workloads with infrequent writes |
| Atomics | Lock-free reads | Lock-free CAS | Single values, counters, flags |
| `parking_lot::Mutex` | Faster than std | Faster than std | Drop-in replacement when performance matters |
| `parking_lot::RwLock` | Faster, fair | Faster, fair | Read-heavy with fairness requirements |
**Check for**:
- `RwLock` where writes are frequent — reader/writer lock overhead may exceed a simple `Mutex`
- `Mutex` protecting a single integer — an atomic is simpler and lock-free
- `std::sync::Mutex` in async code held across `.await` — use `tokio::sync::Mutex` instead (see async-concurrency.md)
## Lock Ordering and Deadlock Prevention
**Flag when**: code acquires multiple locks without a documented ordering. Two threads acquiring locks in different orders will deadlock.
```rust
// DEADLOCK RISK — thread 1 locks A then B, thread 2 locks B then A
let _a = lock_a.lock().unwrap();
let _b = lock_b.lock().unwrap();
// SAFE — document and enforce a global lock ordering
// Rule: always acquire lock_a before lock_b
```
Prevention strategies:
- **Global lock ordering**: document which locks must be acquired first. Enforce in code review.
- **Lock splitting**: use finer-grained locks that are never held simultaneously
- **Lock-free algorithms**: avoid locks entirely with atomics and `compare_exchange`
- **`try_lock` with backoff**: detect contention and retry
## Common Concurrency Bugs to Flag
1. **Data races**: mutation of non-atomic shared state without synchronization — always undefined behavior
2. **Lock held across await**: `MutexGuard` alive at `.await` point (see async-concurrency.md)
3. **Incorrect `Send`/`Sync`**: manual implementations missing generic bounds
4. **TOCTOU (time-of-check-to-time-of-use)**: checking a condition then acting on it without holding the lock
5. **Forgetting to join spawned threads**: fire-and-forget threads with `thread::spawn` may outlive the data they reference
6. **`compare_exchange` without loop**: CAS can spuriously fail on some architectures — use `compare_exchange_weak` in a loop for better performance
```rust
// BAD — single compare_exchange may fail spuriously
let old = val.compare_exchange(0, 1, Ordering::AcqRel, Ordering::Relaxed);
// GOOD — loop for retry (unless you handle failure explicitly)
loop {
match val.compare_exchange_weak(0, 1, Ordering::AcqRel, Ordering::Relaxed) {
Ok(_) => break,
Err(_) => continue,
}
}
```
## `std::thread::scope` for Bounded Thread Lifetimes
Scoped threads (stable since 1.63) borrow non-`'static` data safely by guaranteeing all threads join before the scope exits.
```rust
let mut data = vec![1, 2, 3];
std::thread::scope(|s| {
s.spawn(|| {
println!("{:?}", &data); // borrows data — no Arc needed
});
s.spawn(|| {
println!("len: {}", data.len());
});
}); // all threads joined here — data is safe to use again
```
**Flag when**: `Arc<T>` is used to share data with threads that are joined before the function returns — `thread::scope` is simpler and avoids the allocation.
## OnceLock / LazyLock Patterns
`OnceLock` stable since 1.70, `LazyLock` stable since 1.80. Replace `once_cell` and `lazy_static` for new code.
```rust
use std::sync::{LazyLock, OnceLock};
// LazyLock: initialize with a closure, computed on first access
static CONFIG: LazyLock<Config> = LazyLock::new(|| load_config());
// OnceLock: initialize at runtime, set exactly once
static DB: OnceLock<Database> = OnceLock::new();
fn init_db(conn_str: &str) {
DB.set(Database::connect(conn_str)).expect("DB already initialized");
}
```
**Flag when**: new code (MSRV >= 1.80) uses `once_cell::sync::Lazy` or `lazy_static!` — prefer `LazyLock`/`OnceLock` from std.
## crossbeam and parking_lot Patterns
### crossbeam
- `crossbeam::channel`: faster, more ergonomic channels than `std::sync::mpsc`. Supports `select!` over multiple channels.
- `crossbeam::epoch`: epoch-based memory reclamation for lock-free data structures
- `crossbeam::utils::CachePadded`: wraps a value to occupy a full cache line, preventing false sharing
**Flag when**: hot concurrent counters or flags are in adjacent memory without cache-line padding — likely false sharing.
### parking_lot
- Drop-in replacements for `std::sync::{Mutex, RwLock, Condvar, Once}`
- Faster on contended workloads, smaller `Mutex` size (1 byte vs 40+ bytes on Linux)
- Provides `MutexGuard::map` for projecting through a lock
**Valid pattern**: `parking_lot::Mutex` over `std::sync::Mutex` when benchmarks show contention is a bottleneck.
## Review Questions
1. Are `Send`/`Sync` implementations bounded on generic parameters?
2. Is the memory ordering for each atomic operation sufficient for its use case?
3. Are multiple locks acquired in a consistent, documented order?
4. Could `thread::scope` replace `Arc` for data shared with joined threads?
5. Are `once_cell`/`lazy_static` uses replaceable with `OnceLock`/`LazyLock` (MSRV >= 1.80)?
6. Is there false sharing risk from adjacent atomic values without cache-line padding?
7. Are `compare_exchange` operations in a retry loop when spurious failure is possible?
FILE:references/error-handling.md
# Error Handling
## Critical Anti-Patterns
### 1. Unwrap in Production Code
`unwrap()` and `expect()` panic on `None`/`Err`, crashing the program. They bypass the type system's error safety guarantees.
```rust
// BAD - panics on invalid input
fn parse_config(input: &str) -> Config {
let value: Config = serde_json::from_str(input).unwrap();
value
}
// GOOD - propagates error to caller
fn parse_config(input: &str) -> Result<Config, serde_json::Error> {
serde_json::from_str(input)
}
```
`unwrap()` is acceptable in: tests, examples, and after a check that guarantees success (e.g., `if option.is_some() { option.unwrap() }` — though `.unwrap()` after match/if-let is cleaner).
### 2. Errors Without Context
Bare `?` propagation loses the "what was being attempted" context, making debugging difficult.
```rust
// BAD - caller sees "file not found" with no context
fn load_config(path: &Path) -> Result<Config, Error> {
let contents = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}
// GOOD - each operation adds context
fn load_config(path: &Path) -> Result<Config, Error> {
let contents = std::fs::read_to_string(path)
.map_err(|e| Error::ConfigRead { path: path.to_owned(), source: e })?;
let config: Config = toml::from_str(&contents)
.map_err(|e| Error::ConfigParse { path: path.to_owned(), source: e })?;
Ok(config)
}
```
With `anyhow`, use `.context()` / `.with_context()`:
```rust
use anyhow::Context;
fn load_config(path: &Path) -> anyhow::Result<Config> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("reading config from {}", path.display()))?;
let config: Config = toml::from_str(&contents)
.context("parsing config TOML")?;
Ok(config)
}
```
### 3. Stringly-Typed Errors
Using `String` as an error type loses structured information and makes error matching impossible.
```rust
// BAD - callers can't match on error types
fn validate(input: &str) -> Result<(), String> {
if input.is_empty() {
return Err("input is empty".to_string());
}
Ok(())
}
// GOOD - structured error types
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("input is empty")]
Empty,
#[error("input too long: {len} chars (max {max})")]
TooLong { len: usize, max: usize },
}
fn validate(input: &str) -> Result<(), ValidationError> {
if input.is_empty() {
return Err(ValidationError::Empty);
}
Ok(())
}
```
### 4. Panic for Recoverable Errors
`panic!` should be reserved for unrecoverable states (violated invariants, programmer bugs). Expected failures like I/O errors, parse failures, or network issues should return `Result`.
```rust
// BAD
fn connect(url: &str) -> Connection {
TcpStream::connect(url).unwrap_or_else(|e| panic!("connection failed: {e}"))
}
// GOOD
fn connect(url: &str) -> Result<Connection, ConnectionError> {
let stream = TcpStream::connect(url)
.map_err(|e| ConnectionError::TcpFailed { url: url.to_owned(), source: e })?;
Ok(Connection::new(stream))
}
```
### 5. Swallowing Errors
Discarding errors silently makes failures invisible.
```rust
// BAD - error silently ignored
let _ = save_to_disk(&data);
// GOOD - log if you can't propagate
if let Err(e) = save_to_disk(&data) {
tracing::error!(error = %e, "failed to save data to disk");
}
```
The exception: some errors are genuinely unactionable (e.g., `write!` to stderr, `close()` on a file you're done with). In those cases, `let _ =` with a brief comment is acceptable.
## Let-Else for Early Returns
Rust's `let-else` pattern (stable since 1.65) is cleaner than `match` for early returns on failure:
```rust
// GOOD - flat, readable early return
let Ok(json) = serde_json::from_str(&input) else {
return Err(MyError::InvalidJson);
};
// GOOD - continue/break in loops
for item in items {
let Some(value) = item.value() else {
continue;
};
process(value);
}
// Use if-let when the else branch needs computation
if let Some(result) = cache.get(&key) {
return Ok(result.clone());
} else {
let computed = expensive_compute(&key)?;
cache.insert(key, computed.clone());
return Ok(computed);
}
// NOTE (Edition 2024): Temporaries in if-let conditions are dropped
// at the end of the condition, not the end of the block. If the
// matched value borrows a temporary, bind it explicitly first.
// See ownership-borrowing.md for details.
```
## Prevent Early Allocation
Use `_else` variants when the fallback involves allocation or computation:
```rust
// BAD - format! runs even when x is Some
let val = x.ok_or(ParseError::Missing(format!("key {key}")));
// GOOD - closure only runs on None
let val = x.ok_or_else(|| ParseError::Missing(format!("key {key}")));
// BAD - Vec::new() allocates even on Ok path
let items = result.unwrap_or(Vec::new());
// GOOD - use unwrap_or_default for Default types
let items = result.unwrap_or_default();
```
## Logging and Transforming Errors
Use `inspect_err` to log and `map_err` to transform errors in a chain:
```rust
let result = do_something()
.inspect_err(|err| tracing::error!("do_something failed: {err}"))
.map_err(|err| AppError::from(("do_something", err)))?;
```
## Custom Error Structs
When a module has only one error type, a struct is simpler than an enum:
```rust
#[derive(Debug, thiserror::Error, PartialEq)]
#[error("Request failed with code `{code}`: {message}")]
struct HttpError {
code: u16,
message: String,
}
```
## Async Error Bounds
Errors in async code must be `Send + Sync + 'static` for spawned tasks:
```rust
// Ensure error types work across await boundaries
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(())
}
```
Avoid `Box<dyn std::error::Error>` (without Send + Sync) in libraries.
## Panic Alternatives
Prefer these over `panic!` for expected incomplete code:
| Macro | Use When |
|-------|----------|
| `todo!()` | Code not yet written — alerts compiler of missing implementation |
| `unreachable!()` | Logic guarantees this branch can't execute |
| `unimplemented!()` | Feature intentionally not implemented, with reason |
## thiserror Patterns
`thiserror` generates `Display` and `Error` implementations from derive macros. It's the standard choice for library error types.
```rust
#[derive(Debug, thiserror::Error)]
pub enum Error {
// Transparent: delegates Display and source() to inner error
#[error(transparent)]
Io(#[from] std::io::Error),
// Structured: carries context alongside the cause
#[error("failed to parse config at {path}")]
ConfigParse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
// Simple: no underlying cause
#[error("workflow not found: {0}")]
NotFound(Uuid),
// Multiple sources via transparent wrapping
#[error(transparent)]
Database(#[from] sqlx::Error),
}
```
Hierarchical errors: subsystem error types wrap into a top-level error via `#[from]`:
```rust
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error(transparent)]
Workflow(#[from] WorkflowError),
#[error(transparent)]
Driver(#[from] DriverError),
}
```
## Result Type Alias Pattern
Crates commonly define a local `Result` alias to reduce boilerplate:
```rust
pub type Result<T> = std::result::Result<T, Error>;
// Now functions in this module just use:
pub fn load(path: &Path) -> Result<Config> { ... }
```
## Option Handling
`Option<T>` represents absence, not failure. Converting between `Option` and `Result` should be explicit about what "missing" means:
```rust
// BAD - ok_or with allocated string
let user = users.get(id).ok_or("user not found".to_string())?;
// GOOD - specific error type
let user = users.get(id).ok_or(Error::NotFound(id))?;
// GOOD - ok_or_else for expensive error construction
let user = users.get(id).ok_or_else(|| Error::NotFound(id))?;
```
## Error Trait Implementation Rules
Custom error types should implement the full Error contract for ecosystem compatibility:
1. **`Error` trait**: implement `std::error::Error`
2. **`Display`**: one-line, lowercase, no trailing punctuation — fits into larger error reports
3. **`Debug`**: usually `#[derive(Debug)]` is sufficient; include auxiliary info (ports, paths, request IDs)
4. **`Send + Sync`**: required for multithreaded contexts and `std::io::Error` wrapping
5. **`'static`**: enables downcasting and easy propagation up the call stack
```rust
#[derive(Debug)]
pub struct DecodeError {
offset: usize,
kind: DecodeErrorKind,
}
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// lowercase, no trailing punctuation
write!(f, "decode failed at offset {}: {}", self.offset, self.kind)
}
}
impl std::error::Error for DecodeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.kind.source()
}
}
```
**Flag when**: a custom error type is missing `Display`, `Debug`, or `Error` implementations. With `thiserror`, these are derived automatically.
## Enumerated vs Opaque Error Strategy
Choose between enumerated and opaque errors based on whether callers need to distinguish error cases:
| Strategy | When to Use | Example |
|----------|-------------|---------|
| **Enumerated** (`enum`) | Callers take different actions per error variant | I/O vs parse vs auth errors in a web handler |
| **Opaque** (`Box<dyn Error>` or struct) | Callers only log/propagate, don't match on variants | Image decoder, internal library errors |
**Flag when**:
- A library exposes `Box<dyn Error>` when callers demonstrably need to match on specific error cases
- An error enum has 20+ variants that callers never match on — consider an opaque wrapper to simplify the API
## Error Chain Traversal with `source()`
`Error::source()` provides the underlying cause, enabling error chain traversal for backtraces and diagnostics.
```rust
fn print_error_chain(err: &dyn std::error::Error) {
let mut current = Some(err);
while let Some(e) = current {
eprintln!(" caused by: {e}");
current = e.source();
}
}
```
**Check for**: error types that wrap an inner error but don't implement `source()` — the chain breaks and root cause is hidden.
With `thiserror`, `#[source]` and `#[from]` attributes handle this automatically:
```rust
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("database query failed")]
Database {
#[source] // wires up Error::source()
source: sqlx::Error,
},
}
```
## Type-Erased Error Composition
`Box<dyn Error + Send + Sync + 'static>` enables heterogeneous error handling in applications:
```rust
fn process() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
let data = std::fs::read_to_string("input.txt")?; // io::Error
let parsed: Config = toml::from_str(&data)?; // toml::de::Error
Ok(())
}
```
**Check for**: `Box<dyn Error>` (without `Send + Sync`) in library code — this prevents use in multithreaded contexts. Always prefer `Box<dyn Error + Send + Sync + 'static>` or a concrete type.
Note: `Box<dyn Error + Send + Sync + 'static>` itself does not implement `Error`. If you need a type-erased error that also implements `Error`, define a wrapper type or use `anyhow::Error`.
## Downcasting with `Error::downcast_ref()`
Downcasting recovers the concrete error type from a `dyn Error`. Requires the `'static` bound.
```rust
fn handle_error(err: &(dyn std::error::Error + 'static)) {
if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
if io_err.kind() == std::io::ErrorKind::WouldBlock {
// handle non-blocking retry
return;
}
}
// generic error handling
eprintln!("error: {err}");
}
```
**Flag when**: error types are not `'static` — this prevents downcasting and limits composability. Avoid placing non-static references in error types unless strictly necessary.
## `From` Implementations for `?` Ergonomics
The `?` operator uses `From` to convert between error types. Implementing `From<SourceError> for MyError` enables seamless `?` propagation.
```rust
// Manual From implementation
impl From<std::io::Error> for AppError {
fn from(err: std::io::Error) -> Self {
AppError::Io(err)
}
}
// With thiserror — #[from] generates the From impl
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error(transparent)]
Io(#[from] std::io::Error),
}
```
**Check for**:
- Missing `From` implementations causing verbose `.map_err()` chains when `?` would suffice
- Implement `From`, not `Into` — the `?` operator uses `From` internally
- Conflicting `#[from]` attributes: two variants with `#[from]` for the same source type won't compile
## Try Blocks for Scoped Error Handling
Try blocks (still unstable as of Edition 2024, behind `#![feature(try_blocks)]`) scope `?` to a block instead of the entire function:
```rust
#![feature(try_blocks)]
fn do_work() -> Result<(), Error> {
let resource = Resource::acquire()?;
let result: Result<(), Error> = try {
step_one(&resource)?;
step_two(&resource)?;
};
resource.cleanup(); // always runs, even if steps failed
result
}
```
**Check for**: functions that need cleanup before returning errors — `try` blocks avoid the pattern of manually catching and re-raising. Until stabilized, the drop-guard or RAII pattern is the stable alternative.
## Review Questions
1. Are all `unwrap()` / `expect()` calls in production code justified?
2. Do errors carry context about what operation failed?
3. Are error types structured (enums/structs) rather than stringly-typed?
4. Is `panic!` reserved for unrecoverable invariant violations?
5. Are errors propagated or logged, not silently swallowed?
6. Is `thiserror` used for library errors, `anyhow` for application errors?
7. Are `_else` variants used when fallbacks involve allocation?
8. Do async error types satisfy `Send + Sync + 'static` bounds?
9. Is `inspect_err` used for error logging instead of match arms?
10. Do custom error types implement the full contract (`Error`, `Display`, `Debug`, `Send + Sync + 'static`)?
11. Is `Error::source()` implemented for wrapped errors to enable chain traversal?
12. Are `From` implementations provided for `?` ergonomics instead of verbose `.map_err()` chains?
13. Is the error strategy (enumerated vs opaque) appropriate for how callers interact with errors?
FILE:references/lifetime-variance.md
# Lifetime Variance
## Variance Fundamentals
Variance describes when a subtype can substitute for a supertype. A lifetime `'b` is a subtype of `'a` if `'b: 'a` (outlives). There are three kinds:
- **Covariant**: a subtype can substitute freely. `&'a T` is covariant in both `'a` and `T`.
- **Invariant**: must match exactly. `&mut T` is invariant in `T`; `Cell<T>` is invariant in `T`.
- **Contravariant**: flipped relationship. `fn(T)` is contravariant in `T` (a function accepting less-specific args is more useful).
### Quick Reference Table
| Type | Variance in `'a` | Variance in `T` |
|------|-------------------|------------------|
| `&'a T` | covariant | covariant |
| `&'a mut T` | covariant | **invariant** |
| `Cell<T>` / `RefCell<T>` | — | **invariant** |
| `*const T` / `NonNull<T>` | — | covariant |
| `*mut T` | — | **invariant** |
| `fn(T) -> U` | — | **contra** in `T`, covariant in `U` |
| `Box<T>` / `Vec<T>` | — | covariant |
| `UnsafeCell<T>` | — | **invariant** |
## When Variance Causes Bugs
### Invariance Behind `&mut`
**Flag when**: a type uses a single lifetime where two are needed, and one appears behind `&mut`.
```rust
// BAD — single lifetime forces invariance, won't compile
struct MutStr<'a> {
s: &'a mut &'a str,
}
// GOOD — two lifetimes decouple the mutable borrow from the inner reference
struct MutStr<'a, 'b> {
s: &'a mut &'b str,
}
```
With one lifetime, the compiler cannot shorten the mutable borrow independently of the inner `&str` because `&mut T` is invariant in `T`. Two lifetimes let the outer borrow end while the inner `&str` lifetime remains `'static`.
### `Cell<&'a T>` Is Invariant
**Flag when**: `Cell` or `RefCell` wraps a reference and code assumes lifetime covariance.
```rust
// This won't compile — Cell<&'a T> is invariant in 'a
fn bad<'a>(cell: &Cell<&'a str>) {
let local = String::from("temp");
cell.set(&local); // would allow dangling ref if covariant
}
```
Invariance is correct here: if `Cell<&'a str>` were covariant, you could store a short-lived reference that outlives its source.
## Memory Regions and Lifetime Implications
- **Stack**: references to stack frames cannot outlive the frame. Check that returned references don't point to locals.
- **Heap** (`Box`, `Arc`): lifetime is unconstrained until deallocation. `Box::leak` produces `&'static`.
- **Static memory**: `'static` references to `static` variables or string literals are always valid.
**Check for**: `'static` bounds on type parameters (e.g., `T: 'static`) — this means `T` must be owned or itself `'static`, not that it lives forever. Common in `thread::spawn` and `tokio::spawn` closures.
## Lifetime Annotation Review Rules
### Flag When
- **Unnecessary `'static` on parameters**: `fn process(name: &'static str)` when `&str` suffices. This forces callers to provide only compile-time strings or leaked allocations.
- **Missing multiple lifetimes**: a struct holds references to two independent sources but uses one lifetime, causing invariance-related compilation failures or overly restrictive APIs.
- **Lifetime on return but not needed**: functions returning owned data (`String`, `Vec<T>`) that carry a phantom lifetime parameter.
- **Single lifetime on iterator types**: types like `StrSplit` that yield references from one field but borrow a different field need separate lifetimes so the yielded reference isn't tied to the shorter-lived field.
### Valid Patterns
- **Elided lifetimes**: when the three elision rules apply, explicit annotations are noise. Don't flag missing annotations when elision handles it.
- **`'a` on `&self` returns**: rule 3 of elision assigns `self`'s lifetime to outputs. Explicit annotation is optional.
- **`'static` for `thread::spawn` and `tokio::spawn`**: required by the API, not a code smell.
- **`'static` trait bounds**: `T: 'static` on generic parameters is standard for owned, self-sufficient types.
## Common Mistakes
### Conflating `'static` with "lives forever"
`T: 'static` means `T` contains no non-static borrows. An owned `String` is `'static`. This is a bound, not a lifetime annotation on a reference.
### Ignoring Drop's interaction with lifetimes
If a type implements `Drop` and is generic over `'a`, dropping counts as a use of `'a`. Code that shortens borrows before the drop site may fail to compile.
```rust
// If Wrapper<'a> implements Drop, this won't compile:
let mut x = 42;
let w = Wrapper(&mut x);
x = 0; // x is still mutably borrowed because w.drop() might use it
```
**Check for**: types with `Drop` that hold references — the borrow extends to the drop point, not the last explicit use.
### **Edition 2024**: RPIT captures all lifetimes by default
In edition 2024, `-> impl Trait` captures all in-scope lifetime parameters. Use `+ use<'a>` to narrow capture when the return value doesn't actually borrow all parameters.
## Review Questions
1. Does the type need multiple lifetime parameters, or does a single lifetime cause invariance issues?
2. Are `'static` annotations on parameters genuinely required, or would an elided lifetime work?
3. Do types implementing `Drop` account for the extended borrow at the drop site?
4. Are `Cell`/`RefCell` wrapping references with correct variance expectations?
5. **Edition 2024**: Do RPIT return types need `+ use<...>` to avoid capturing unrelated lifetimes?
FILE:references/ownership-borrowing.md
# Ownership and Borrowing
For pointer type selection, Copy trait guidance, Cow patterns, and iterator idioms, see the `beagle-rust:rust-best-practices` skill.
## Critical Anti-Patterns
### 1. Clone to Silence the Borrow Checker
When `.clone()` appears primarily to resolve borrow checker errors, it often hides a design issue. The borrow checker is pointing at a real ownership conflict that cloning papers over.
```rust
// BAD - cloning to work around borrow conflict
fn process(data: &mut Vec<String>) {
let items = data.clone(); // expensive, hides design issue
for item in &items {
data.push(item.to_uppercase());
}
}
// GOOD - restructure to avoid the conflict
fn process(data: &mut Vec<String>) {
let uppercased: Vec<String> = data.iter().map(|s| s.to_uppercase()).collect();
data.extend(uppercased);
}
```
The exception: `.clone()` is fine when you genuinely need an independent copy (e.g., sending data to another thread, storing in a cache alongside the original).
### 2. Overly Broad Lifetimes
Using `'static` when a shorter lifetime works makes APIs inflexible and can hide real ownership issues.
```rust
// BAD - forces callers to own their data forever
fn process(name: &'static str) {
println!("{name}");
}
// GOOD - any borrowed string works
fn process(name: &str) {
println!("{name}");
}
```
`'static` is appropriate for: compile-time constants, leaked allocations (intentional), thread-spawned closures that must outlive the caller.
### 3. Taking Ownership When Borrowing Suffices
Functions that take `String` when they only read the data force unnecessary allocations at call sites.
```rust
// BAD - forces callers to allocate
fn greet(name: String) {
println!("Hello, {name}");
}
greet(some_str.to_string()); // unnecessary allocation
// GOOD - borrows are cheaper
fn greet(name: &str) {
println!("Hello, {name}");
}
greet(some_str); // works with &str, String, &String
```
For maximum flexibility in public APIs, consider `impl AsRef<str>` which accepts `&str`, `String`, `&String`, and other types that deref to `str`.
### 4. Returning References to Local Data
The borrow checker catches this at compile time, but it indicates a misunderstanding of ownership.
```rust
// WON'T COMPILE - but indicates design confusion
fn create_name() -> &str {
let name = String::from("hello");
&name // name is dropped at end of function
}
// GOOD - return owned data
fn create_name() -> String {
String::from("hello")
}
```
### 5. Interior Mutability Overuse
`RefCell`, `Cell`, and `Mutex` bypass compile-time borrow checking. Overusing them suggests the ownership model needs rethinking.
```rust
// SUSPICIOUS - RefCell to work around borrow rules
struct Service {
cache: RefCell<HashMap<String, Data>>,
config: RefCell<Config>,
}
// BETTER - separate mutable and immutable state
struct Service {
cache: HashMap<String, Data>, // mutated via &mut self
config: Config, // set at construction
}
```
`RefCell` is appropriate for: observer patterns, graph structures with shared nodes, runtime-polymorphic mutation. In multithreaded code, `Mutex`/`RwLock` serve a similar role but with thread safety.
## Lifetime Elision Rules
Rust elides lifetimes when the rules are unambiguous. Understanding them prevents unnecessary lifetime annotations:
1. Each input reference gets its own lifetime: `fn f(a: &T, b: &U)` becomes `fn f<'a, 'b>(a: &'a T, b: &'b U)`
2. If there's exactly one input lifetime, it's assigned to all output references
3. If `&self` or `&mut self` is an input, its lifetime is assigned to outputs
```rust
// Elision handles this - no annotations needed
fn first_word(s: &str) -> &str { ... }
// Multiple inputs, ambiguous - must annotate
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { ... }
```
## RPIT Lifetime Capture (Edition 2024)
In edition 2024, `-> impl Trait` return types capture **all** in-scope generic parameters and lifetimes by default. In edition 2021, only type parameters used in the bounds were captured.
```rust
// Edition 2021: this compiled — 'a is NOT captured by impl Display
fn foo<'a>(x: &'a str, y: String) -> impl Display {
y // fine: returned value doesn't borrow 'a
}
// Edition 2024: same code captures 'a — returned impl Display now
// borrows 'a even though it doesn't use it. This can cause
// unexpected borrow checker errors at call sites.
// GOOD — use precise capturing to opt out of capturing 'a
fn foo<'a>(x: &'a str, y: String) -> impl Display + use<> {
y // explicitly captures nothing
}
// GOOD — capture only what you need
fn bar<'a, 'b>(x: &'a str, y: &'b str) -> impl Display + use<'b> {
y.to_uppercase() // only captures 'b
}
```
**When to flag**: Functions returning `impl Trait` that take multiple lifetime parameters — check whether the edition 2024 default capture causes unintended borrowing at call sites. If callers get unexpected "borrowed value does not live long enough" errors, add `+ use<...>` to narrow the capture.
## `if let` Temporary Scope (Edition 2024)
In edition 2024, temporaries created in `if let` conditions are dropped at the end of the condition, not at the end of the `if` block. This breaks patterns that relied on temporaries living through the `else` branch.
```rust
// BAD in edition 2024 — MutexGuard dropped before else branch
if let Some(val) = mutex.lock().unwrap().get("key") {
use_val(val);
} else {
// mutex is already unlocked here — was locked in 2021
}
// GOOD — bind the guard explicitly to control its lifetime
let guard = mutex.lock().unwrap();
if let Some(val) = guard.get("key") {
use_val(val);
} else {
// guard still alive — explicit control
}
```
## Tail Expression Temporary Scope (Edition 2024)
In edition 2024, temporaries in tail expressions (the final expression in a block without a semicolon) are dropped **before** local variables. This can break code where a temporary borrows a local.
```rust
// BAD in edition 2024 — temporary String dropped before local
fn example() -> &str {
let s = String::from("hello");
s.as_str() // temporary borrow dropped before s in 2024
}
// GOOD — return owned data or bind explicitly
fn example() -> String {
String::from("hello")
}
```
**When to flag**: Tail expressions that create temporaries referencing local variables — in edition 2024 the drop order changed and this may cause borrow checker errors that didn't exist in 2021.
## `IntoIterator` for `Box<[T]>` (Edition 2024)
`Box<[T]>` now implements `IntoIterator` directly in edition 2024, yielding owned `T` values without converting to `Vec` first.
```rust
// BEFORE edition 2024 — had to convert to Vec
let boxed: Box<[i32]> = vec![1, 2, 3].into_boxed_slice();
for item in boxed.into_vec() {
process(item);
}
// GOOD in edition 2024 — iterate directly
let boxed: Box<[i32]> = vec![1, 2, 3].into_boxed_slice();
for item in boxed {
process(item);
}
```
## Review Questions
1. Are `.clone()` calls necessary, or do they mask ownership design issues?
2. Are lifetimes as narrow as possible (not overly `'static`)?
3. Do functions borrow when they don't need ownership?
4. Is interior mutability (`RefCell`, `Cell`) used only when compile-time borrowing is genuinely insufficient?
5. Are smart pointers chosen appropriately for the sharing and threading model?
6. **Edition 2024**: Do `-> impl Trait` returns use `+ use<...>` when default lifetime capture is too broad?
7. **Edition 2024**: Are `if let` temporaries explicitly bound when their lifetime matters?
8. **Edition 2024**: Are tail expression temporaries safe given the new drop order?
FILE:references/types-layout.md
# Types and Layout
## Type Layout in Memory
### Alignment and Padding
Every type has an alignment determined by its largest field. Fields may require padding to satisfy alignment constraints.
```rust
#[repr(C)]
struct Foo {
tiny: bool, // 1 byte + 3 bytes padding
normal: u32, // 4 bytes (4-byte aligned)
small: u8, // 1 byte + 7 bytes padding
long: u64, // 8 bytes (8-byte aligned)
short: u16, // 2 bytes + 6 bytes padding (struct alignment)
}
// repr(C) total: 32 bytes
// repr(Rust) can reorder fields: 16 bytes (no padding needed)
```
**Check for**: `#[repr(C)]` types with suboptimal field ordering — padding can significantly inflate size. With default `repr(Rust)`, the compiler reorders fields for you.
### `repr` Variants
| Repr | Guarantees | Use Case |
|------|-----------|----------|
| `repr(Rust)` (default) | No layout guarantees, compiler optimizes freely | Internal types |
| `repr(C)` | Predictable C-compatible layout, field order preserved | FFI, raw pointer casts between types |
| `repr(transparent)` | Outer type has identical layout to its single field | Newtypes used in FFI or pointer casts |
| `repr(packed)` | No padding, may cause misaligned access | Size-critical data, network protocols |
| `repr(align(N))` | Minimum alignment of N bytes | Cache line isolation, SIMD |
**Flag when**:
- FFI types lack `#[repr(C)]` — default Rust layout is not stable across compiler versions
- Pointer casts between types assume identical layout without `repr(C)` or `repr(transparent)`
- `repr(packed)` used without awareness of performance cost from misaligned access
- Types used in concurrent shared arrays lack `repr(align(64))` for cache-line padding when false sharing is a concern
## PhantomData Usage Patterns
`PhantomData<T>` is a zero-sized type that tells the compiler "this type logically owns/references a `T`."
### When Required
- **Drop check**: if your type owns a `T` behind a raw pointer, add `PhantomData<T>` so the drop check knows you'll drop `T`
- **Lifetime association**: `PhantomData<&'a T>` ties a lifetime to a type that holds `*const T`
- **Variance control**: `PhantomData<fn(T)>` makes a type contravariant in `T`; `PhantomData<*mut T>` makes it invariant
```rust
struct Iter<'a, T> {
ptr: *const T,
end: *const T,
_marker: PhantomData<&'a T>, // ties lifetime 'a to this type
}
```
### When Over-Used
**Flag when**: `PhantomData` appears in types that already hold a real `T` — it's redundant and adds confusion. Only needed when the `T` is behind a raw pointer or absent from fields.
## Zero-Sized Types (ZST)
ZSTs occupy no memory and are optimized away at compile time. Common uses:
- **Marker types**: `struct Authenticated;` for type-state patterns
- **PhantomData**: compile-time lifetime and variance markers
- **Empty iterators**: `std::iter::Empty<T>` is zero-sized
- **Map keys as sets**: `HashMap<K, ()>` (though `HashSet` is preferred)
**Check for**: allocation of ZSTs is valid but produces a dangling pointer — `Box<()>` doesn't allocate. This is correct behavior, not a bug.
**Valid pattern**: ZSTs as generic parameters for compile-time state machines (type-state pattern). No runtime cost.
## Trait Objects vs Generics
### Static Dispatch (Generics / `impl Trait`)
- Monomorphized: compiler generates a copy per concrete type
- Zero overhead: calls are direct, inlinable
- **Cost**: increased compile time and binary size from monomorphization
### Dynamic Dispatch (`dyn Trait`)
- Single copy of code, vtable indirection per method call
- Enables heterogeneous collections (`Vec<Box<dyn Trait>>`)
- **Cost**: vtable lookup per call, prevents inlining, requires allocation behind a pointer
### Decision Checklist
| Criterion | Use Generics | Use `dyn Trait` |
|-----------|-------------|-----------------|
| Library API | Prefer (caller chooses) | Only if object safety needed |
| Binary code | Either works | Prefer (smaller binary, faster compile) |
| Hot path | Prefer (zero-cost inlining) | Avoid (vtable overhead) |
| Heterogeneous collection | Not possible | Required |
| Compile time concern | May be slow | Faster |
**Flag when**: `dyn Trait` used on a hot path where a generic would allow inlining, or generics used with dozens of instantiations causing compile-time bloat in a binary crate.
## Monomorphization Code Bloat
**Check for**: generic functions with large bodies instantiated for many types. The compiler copies the entire function body per type.
Mitigation patterns:
- **Non-generic inner functions**: extract type-independent logic into a non-generic helper
```rust
fn process<T: Hash>(items: &[T]) {
// Type-dependent: hashing
let hashes: Vec<u64> = items.iter().map(|i| hash(i)).collect();
// Type-independent: extracted to share machine code
process_hashes(&hashes);
}
fn process_hashes(hashes: &[u64]) {
// This code is compiled once, not per T
}
```
- **`dyn Trait` for binary internals**: when the binary doesn't need peak per-call performance, dynamic dispatch reduces code size
- **Bounded generics in libraries**: use `impl Trait` in argument position for flexibility while keeping the generic surface small
## Dynamically Sized Types
`dyn Trait` and `[T]` are `!Sized` — they must live behind a wide pointer (`&`, `Box`, `Arc`).
**Flag when**:
- A type bound is missing `?Sized` when it should accept DSTs
- Code assumes `size_of::<dyn Trait>()` — this doesn't compile because the size is unknown
- A struct stores `dyn Trait` as a field without indirection (won't compile)
**Valid pattern**: `Box<dyn Error + Send + Sync + 'static>` for heterogeneous error handling in application code.
## Review Questions
1. Do FFI types use `#[repr(C)]` or `#[repr(transparent)]`?
2. Are pointer casts between types justified by matching repr guarantees?
3. Is `PhantomData` present where needed for drop check and variance, and absent where redundant?
4. Are generics causing code bloat that could be mitigated with inner functions or `dyn Trait`?
5. Is `repr(packed)` used with awareness of misaligned access costs?
6. Are cache-sensitive concurrent types aligned to cache line boundaries?
FILE:references/unsafe-deep.md
# Unsafe Code: Deep Review
For basic unsafe patterns and Edition 2024 changes, see [common-mistakes.md](common-mistakes.md).
## Safety Contracts and Documentation
Every `unsafe fn` must document the conditions under which it is safe to call. Every `unsafe {}` block must explain why those conditions are met.
**Flag when**:
- An `unsafe fn` has no `# Safety` section in its doc comment
- An `unsafe {}` block has no `// SAFETY:` comment
- A safety comment says "this is safe" without explaining the invariant
### Documentation Template
```rust
/// Reconstructs a `Foo` from its raw parts.
///
/// # Safety
///
/// - `ptr` must have been obtained from `Foo::into_raw`
/// - `ptr` must not have been freed since that call
/// - The caller must ensure no other `Foo` exists for this pointer
pub unsafe fn from_raw(ptr: *mut Inner) -> Self {
// SAFETY: caller guarantees ptr is valid and uniquely owned
// per the documented contract above.
Self { inner: unsafe { Box::from_raw(ptr) } }
}
```
## Raw Pointer Validity Rules
A raw pointer dereference is safe only when ALL of these hold:
1. **Non-null**: the pointer is not null
2. **Aligned**: the pointer is properly aligned for the target type
3. **Initialized**: the pointed-to memory contains a valid value for the type
4. **Provenance**: the pointer was derived from a valid allocation (not fabricated from an integer without care)
5. **No aliasing violations**: creating `&T` from `*const T` requires no `&mut T` exists; creating `&mut T` requires exclusive access
**Check for**:
- Pointer arithmetic via `.add()`, `.sub()`, `.offset()` — result must stay within the same allocation
- Casts between `*const T` and `*mut T` — does not grant mutable access; aliasing rules still apply
- Integer-to-pointer casts — these have provenance concerns and are almost always wrong
### Pointer Type Selection
| Type | Variance | Null? | Use When |
|------|----------|-------|----------|
| `*const T` | covariant | yes | Would use `&T` but can't name the lifetime |
| `*mut T` | **invariant** | yes | Would use `&mut T` but can't name the lifetime |
| `NonNull<T>` | covariant | no | Non-null `*const T` with niche optimization |
## MaybeUninit Patterns
`MaybeUninit<T>` stores a `T` without requiring it to be valid. The compiler makes no assumptions about the value.
### Correct Pattern
```rust
let mut buf = [MaybeUninit::<u8>::uninit(); 4096];
let mut initialized = 0;
for (i, byte) in source.iter().take(4096).enumerate() {
buf[i] = MaybeUninit::new(*byte);
initialized = i + 1;
}
// SAFETY: buf[..initialized] was written with valid u8 values
let init = unsafe { MaybeUninit::slice_assume_init_ref(&buf[..initialized]) };
```
**Flag when**:
- `assume_init()` called before all bytes are written — undefined behavior
- Reading from `MaybeUninit` via `as_ptr()` on uninitialized portions
- Missing panic safety: if a panic occurs mid-initialization, partially initialized memory must not be assumed valid on drop
### Panic Safety with MaybeUninit
```rust
// BAD — if T::default() panics, Vec::drop reads uninitialized memory
unsafe {
vec.set_len(vec.capacity());
for i in old_len..vec.len() {
*vec.get_unchecked_mut(i) = T::default(); // panic here = UB
}
}
// GOOD — update length only after successful initialization
for i in old_len..vec.capacity() {
vec.push(T::default()); // push handles length correctly
}
```
## UnsafeCell and Interior Mutability
`UnsafeCell<T>` is the **only** correct way to mutate through a shared reference. All safe interior mutability types (`Cell`, `RefCell`, `Mutex`) use it internally.
**Flag when**:
- Code mutates through `&T` without `UnsafeCell` — this is always undefined behavior, even if "it works"
- A type provides `&mut T` from `&self` without going through `UnsafeCell` or an atomic
- The shared reference immutability invariant is violated transitively: an `&Foo` where `Foo` contains `*mut T` that gets mutated without `UnsafeCell` wrapping
## Soundness
An abstraction is **sound** if no sequence of safe calls can trigger undefined behavior. An abstraction is **unsound** if safe code can cause UB.
### Soundness Checklist
1. Can safe callers cause a raw pointer dereference of an invalid pointer?
2. Can safe callers break an invariant that `unsafe` blocks depend on?
3. Does the public API expose enough to invalidate internal safety assumptions?
4. Are `Send`/`Sync` implementations correct? Missing bounds on generics? (`unsafe impl<T: Send> Send for MyType<T> {}`)
5. Could a safe `Unpin` implementation break pinning invariants?
6. Does implementing `Drop` access data that might already be dangling?
**Flag when**:
- An `unsafe impl Send` or `unsafe impl Sync` lacks bounds on generic parameters
- Safe public methods can put the type into a state where internal `unsafe` becomes invalid
- A privacy boundary is too wide — fields that unsafe code depends on are `pub` or `pub(crate)` without justification
## Unsafe Code Review Checklist
| Check | What to Verify |
|-------|---------------|
| Safety comments | Every `unsafe` block and `unsafe fn` has documented invariants |
| Minimal scope | Only the truly unsafe op is inside `unsafe {}` |
| Pointer validity | Non-null, aligned, initialized, within allocation bounds |
| Aliasing | No simultaneous `&T` and `&mut T` to the same memory |
| Panic safety | State is consistent if user-provided code panics mid-operation |
| Drop safety | `Drop` impl doesn't access dangling data; `PhantomData` used for drop check |
| Send/Sync | Manual implementations have correct bounds; raw pointers covered |
| UnsafeCell | All interior mutation goes through `UnsafeCell` |
| FFI | Extern blocks are `unsafe extern` (Edition 2024); signatures match the foreign ABI |
| Casting | Type casts between `repr(Rust)` types are invalid without layout guarantees |
## When to Flag
- **Missing safety comments**: always flag, no exceptions. This is the single most important unsafe review rule.
- **Undocumented invariants**: if the safety of an `unsafe` block depends on an invariant not stated anywhere, flag it.
- **Unnecessary unsafe**: code that could be written safely but uses `unsafe` for convenience or perceived performance. Measure first.
- **Wide unsafe blocks**: safe operations mixed into `unsafe {}` — extract them.
- **`transmute` without `repr` guarantees**: casting between types that are both `repr(Rust)` is never guaranteed to be sound.
## Miri as Verification Tool
Miri interprets Rust at the MIR level and detects:
- Use of uninitialized memory
- Out-of-bounds pointer access
- Aliasing violations (Stacked Borrows / Tree Borrows model)
- Use-after-free
- Invalid enum discriminants
- Misaligned references
**Check for**: whether the project runs `cargo +nightly miri test` in CI. For any non-trivial unsafe code, Miri coverage is a strong signal of correctness. Flag when unsafe code lacks Miri test coverage.
## Review Questions
1. Does every `unsafe` block have a `// SAFETY:` comment explaining the invariant?
2. Is the `unsafe` scope minimal — only the truly unsafe operation inside the block?
3. Are raw pointer dereferences provably valid (non-null, aligned, initialized, in-bounds)?
4. Is the code panic-safe — would a panic leave the program in a valid state?
5. Are `Send`/`Sync` implementations bounded correctly on generic parameters?
6. Could any safe public API call sequence trigger undefined behavior?
7. Is Miri used to test unsafe code paths?
Reviews axum web framework code for routing patterns, extractor usage, middleware, state management, and error handling. Use when reviewing Rust code that us...
---
name: axum-code-review
description: Reviews axum web framework code for routing patterns, extractor usage, middleware, state management, and error handling. Use when reviewing Rust code that uses axum, tower, or hyper for HTTP services. Covers axum 0.7+ patterns including State, Path, Query, Json extractors.
---
# Axum Code Review
## Review Workflow
1. **Check Cargo.toml** — Note axum version (0.6 vs 0.7+ have different patterns), Rust edition (2021 vs 2024), tower, tower-http features. Edition 2024 changes RPIT lifetime capture in handler return types and removes the need for `async-trait` in custom extractors.
2. **Check routing** — Route organization, method routing, nested routers
3. **Check extractors** — Order matters (body extractors must be last), correct types
4. **Check state** — Shared state via `State<T>`, not global mutable state
5. **Check error handling** — `IntoResponse` implementations, error types
## Gates (before reporting findings)
Run **in order**. Do not write a finding until the step that applies has passed.
1. **Version and edition on disk** — **Pass when:** You have read the relevant `Cargo.toml` (crate or workspace root) and can state `axum` (and related tower/tower-http) versions and Rust `edition`. **Then** apply 0.6 vs 0.7+ or Edition 2024–specific checklist items only when that file supports them.
2. **Per-finding evidence** — **Pass when:** Each issue cites `[FILE:LINE]` from the **current** tree for the handler, router, layer, or type under review (not from memory, docs-only, or another branch).
3. **Category check vs protocol** — **Pass when:** For the finding type (routing conflict, extractor order, error leak, middleware order, etc.), you ran the matching checks from `beagle-rust:review-verification-protocol` (e.g. full handler signature for extractor order; surrounding error mapping before “raw error to client”). **Then** add the finding.
4. **Output shape** — **Pass when:** The report lines match **Output Format** below (severity + description).
## Output Format
Report findings as:
```text
[FILE:LINE] ISSUE_TITLE
Severity: Critical | Major | Minor | Informational
Description of the issue and why it matters.
```
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Route definitions, nesting, method routing | [references/routing.md](references/routing.md) |
| State, Path, Query, Json, body extractors | [references/extractors.md](references/extractors.md) |
| Tower middleware, layers, error handling | [references/middleware.md](references/middleware.md) |
## Review Checklist
### Routing
- [ ] Routes organized by domain (nested routers for `/api/users`, `/api/orders`)
- [ ] Fallback handlers defined for 404s
- [ ] Method routing explicit (`.get()`, `.post()`, not `.route()` with manual method matching)
- [ ] No route conflicts (overlapping paths with different extractors)
### Extractors
- [ ] Body-consuming extractors (`Json`, `Form`, `Bytes`) are the LAST parameter
- [ ] `State<T>` requires `T: Clone` — typically `Arc<AppState>` or direct `Clone` derive
- [ ] `Path<T>` parameter types match the route definition
- [ ] `Query<T>` fields are `Option` for optional query params with `#[serde(default)]`
- [ ] Custom extractors implement `FromRequestParts` (not body) or `FromRequest` (body)
- [ ] **Edition 2024**: Custom extractors use native `async fn` in trait impls (no `#[async_trait]` needed for `FromRequest`/`FromRequestParts`)
### State Management
- [ ] Application state shared via `State<T>`, not global mutable statics
- [ ] Database pool in state (not created per-request)
- [ ] State contains only shared resources (pool, config, channels), not request-specific data
- [ ] `Clone` derived or manually implemented on state type
- [ ] **Edition 2024**: Shared static state uses `LazyLock` from std (not `once_cell::sync::Lazy` or `lazy_static!`)
### Error Handling
- [ ] Handler errors implement `IntoResponse` for proper HTTP error codes
- [ ] Internal errors don't leak to clients (no raw error messages in 500 responses)
- [ ] Error responses use consistent format (JSON error body with code/message)
- [ ] `Result<impl IntoResponse, AppError>` pattern used for handlers
- [ ] **Edition 2024**: Handler return types `-> impl IntoResponse` capture all in-scope lifetimes by default; use `+ use<>` to opt out of capturing request lifetimes when returning owned data
### Middleware
- [ ] Tower layers applied in correct order (outer runs first on request, last on response)
- [ ] `tower-http` used for common concerns (CORS, compression, tracing, timeout)
- [ ] Request-scoped data passed via extensions, not global state
- [ ] Middleware errors don't panic — they return error responses
- [ ] **Edition 2024**: Middleware using `#[async_trait]` can migrate to native `async fn` in trait impls
## Severity Calibration
### Critical
- Body extractor not last in handler parameters (silently consumes body, later extractors fail)
- SQL injection via path/query parameters passed directly to queries
- Internal error details leaked to clients (stack traces, database errors)
- Missing authentication middleware on protected routes
### Major
- Global mutable state instead of `State<T>` (race conditions)
- Missing error type conversion (raw `sqlx::Error` returned to client)
- Missing request timeout (handlers can hang indefinitely)
- Route conflicts causing unexpected 405s
- **Edition 2024**: `async-trait` still used for `FromRequest`/`FromRequestParts` when native async fn works
### Minor
- Manual route method matching instead of `.get()`, `.post()`
- Missing fallback handler (default 404 is plain text, not JSON)
- Middleware applied per-route when it should be global (or vice versa)
- Missing `tower-http::trace` for request logging
- **Edition 2024**: `once_cell::sync::Lazy` or `lazy_static!` used where `std::sync::LazyLock` works
### Informational
- Suggestions to use `tower-http` layers for common concerns
- Router organization improvements
- Suggestions to add OpenAPI documentation via `utoipa` or `aide`
## Valid Patterns (Do NOT Flag)
- **`#[axum::debug_handler]` on handlers** — Debugging aid that improves compile error messages
- **`Extension<T>` for middleware-injected data** — Valid pattern for request-scoped values
- **Returning `impl IntoResponse` from handlers** — More flexible than concrete types
- **`Router::new()` per module, merged in main** — Standard organization pattern
- **`ServiceBuilder` for layer composition** — Tower pattern, not over-engineering
- **`axum::serve` with `TcpListener`** — Standard axum 0.7+ server setup
- **Native `async fn` in `FromRequest`/`FromRequestParts` impls** — `async-trait` crate no longer needed (stable since Rust 1.75)
- **`+ use<'a>` on handler return types** — Edition 2024 precise capture syntax for RPIT
- **`std::sync::LazyLock` for shared static state** — Replaces `once_cell`/`lazy_static` (stable since Rust 1.80)
## Before Submitting Findings
Complete **Gates (before reporting findings)** and load `beagle-rust:review-verification-protocol` for category-specific checks before any issue is final.
FILE:references/extractors.md
# Extractors
## Extractor Ordering
Body-consuming extractors (`Json`, `Form`, `Bytes`, `String`) must be the LAST parameter. The HTTP body can only be consumed once.
```rust
// BAD - Json consumes body before Path can extract
async fn handler(Json(body): Json<CreateUser>, Path(id): Path<u64>) { ... }
// GOOD - non-body extractors first, body extractor last
async fn handler(Path(id): Path<u64>, Json(body): Json<CreateUser>) { ... }
```
## Common Extractors
### State
Shared application state. The type must implement `Clone`.
```rust
#[derive(Clone)]
struct AppState {
pool: PgPool, // PgPool is Clone (it's an Arc internally)
config: Arc<Config>, // wrap non-Clone types in Arc
}
async fn handler(State(state): State<AppState>) -> impl IntoResponse {
let users = query_as!(User, "SELECT ...").fetch_all(&state.pool).await?;
Json(users)
}
```
### Path
Extract path parameters. Type must implement `Deserialize`.
```rust
// Single parameter
async fn get_user(Path(id): Path<Uuid>) -> impl IntoResponse { ... }
// Multiple parameters
async fn get_comment(
Path((post_id, comment_id)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse { ... }
// Named parameters via struct
#[derive(Deserialize)]
struct CommentPath {
post_id: Uuid,
comment_id: Uuid,
}
async fn get_comment(Path(path): Path<CommentPath>) -> impl IntoResponse { ... }
```
### Query
Extract query string parameters.
```rust
#[derive(Deserialize)]
struct ListParams {
#[serde(default = "default_page")]
page: u32,
#[serde(default = "default_per_page")]
per_page: u32,
search: Option<String>,
}
fn default_page() -> u32 { 1 }
fn default_per_page() -> u32 { 20 }
async fn list_users(Query(params): Query<ListParams>) -> impl IntoResponse { ... }
```
### Json
Deserializes JSON request body. Must be the last extractor.
```rust
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
async fn create_user(
State(state): State<AppState>,
Json(input): Json<CreateUser>,
) -> Result<impl IntoResponse, AppError> {
let user = insert_user(&state.pool, &input).await?;
Ok((StatusCode::CREATED, Json(user)))
}
```
### Extension
For request-scoped data injected by middleware (e.g., authenticated user).
```rust
// Middleware injects
req.extensions_mut().insert(AuthUser { id: user_id });
// Handler extracts
async fn handler(Extension(user): Extension<AuthUser>) -> impl IntoResponse { ... }
```
## Custom Extractors and `async fn` in Traits (Edition 2024)
Since Rust 1.75, `async fn` is stable in trait definitions and implementations. Custom extractors no longer need `#[async_trait]`.
```rust
// BAD (pre-1.75 / unnecessary dependency)
use async_trait::async_trait;
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
// ...
}
}
// GOOD (Rust 1.75+ / edition 2024)
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let token = parts.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
// validate token...
Ok(AuthUser { id: user_id })
}
}
```
Note: `#[async_trait]` is still needed when using `dyn Trait` with async extractors (trait objects require the indirection).
## RPIT Lifetime Capture in Handlers (Edition 2024)
In edition 2024, `-> impl Trait` captures ALL in-scope lifetimes by default. For axum handlers returning owned data, this usually doesn't matter. But when a helper function returns `impl IntoResponse` and has lifetime parameters, it may over-capture:
```rust
// Edition 2024: this captures 'a even though the return is fully owned
fn render_page<'a>(title: &'a str) -> impl IntoResponse {
Html(format!("<h1>{title}</h1>"))
}
// If over-capture causes lifetime issues, use precise capture syntax
fn render_page<'a>(title: &'a str) -> impl IntoResponse + use<> {
Html(format!("<h1>{title}</h1>"))
}
```
Most axum handlers take owned extractors and return owned data, so RPIT capture changes are low-impact. Watch for helper functions with reference parameters returning `impl IntoResponse`.
## Error Handling Pattern
Handlers should return `Result<impl IntoResponse, AppError>` where `AppError` implements `IntoResponse`.
```rust
#[derive(Debug)]
enum AppError {
NotFound(String),
Internal(anyhow::Error),
Validation(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
Self::Internal(err) => {
tracing::error!(error = %err, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
}
Self::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
};
(status, Json(json!({"error": message}))).into_response()
}
}
// Automatic conversion from sqlx errors
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => Self::NotFound("resource not found".into()),
other => Self::Internal(other.into()),
}
}
}
```
## Review Questions
1. Are body-consuming extractors the last parameter?
2. Is `State<T>` used for shared resources (not per-request creation)?
3. Do handler errors implement `IntoResponse` with appropriate status codes?
4. Are internal error details hidden from clients?
5. Are `Path` types aligned with route parameter definitions?
6. Are `Query` fields optional where the query param is optional?
7. Are custom `FromRequest`/`FromRequestParts` impls using native `async fn` instead of `#[async_trait]`?
8. Do helper functions returning `impl IntoResponse` with lifetime params need `+ use<>` precise capture?
FILE:references/middleware.md
# Middleware
## Tower Layer Pattern
Axum uses Tower for middleware. Layers wrap services to add cross-cutting concerns.
```rust
use tower_http::{
cors::CorsLayer,
compression::CompressionLayer,
timeout::TimeoutLayer,
trace::TraceLayer,
};
let app = Router::new()
.route("/api/health", get(health))
.nest("/api/users", users::router())
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(30)))
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive()) // configure properly for production
)
.with_state(state);
```
### Layer Ordering
Layers execute in **reverse order** of how they're added. The last `.layer()` call runs first on the request and last on the response.
```rust
Router::new()
.layer(A) // runs 3rd on request, 1st on response
.layer(B) // runs 2nd on request, 2nd on response
.layer(C) // runs 1st on request, 3rd on response
```
With `ServiceBuilder`, the order is top-to-bottom (more intuitive):
```rust
ServiceBuilder::new()
.layer(C) // runs 1st on request
.layer(B) // runs 2nd on request
.layer(A) // runs 3rd on request
```
## Common tower-http Layers
| Layer | Purpose |
|-------|---------|
| `TraceLayer` | Request/response logging with tracing spans |
| `TimeoutLayer` | Request timeout (returns 408 on timeout) |
| `CorsLayer` | CORS headers |
| `CompressionLayer` | Response compression (gzip, br, etc.) |
| `RequestBodyLimitLayer` | Limit request body size |
| `SetRequestHeaderLayer` | Add/override request headers |
| `PropagateHeaderLayer` | Copy request headers to response |
## Custom Middleware with axum::middleware
For request/response transformation with access to axum extractors:
```rust
use axum::middleware::{self, Next};
async fn auth_middleware(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, AppError> {
let token = req.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let user = validate_token(&state.pool, token).await?;
req.extensions_mut().insert(user);
Ok(next.run(req).await)
}
// Apply to specific routes
let protected = Router::new()
.route("/profile", get(profile))
.route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
let app = Router::new()
.route("/health", get(health)) // unprotected
.merge(protected)
.with_state(state);
```
## Tower Service Trait and `async fn` in Traits (Edition 2024)
Custom Tower `Service` implementations that previously required `#[async_trait]` can now use native `async fn`. However, the Tower `Service` trait itself uses `poll_ready`/`call` (not async fn), so this primarily applies to higher-level abstractions built on top of Tower.
For axum middleware specifically, `axum::middleware::from_fn` already uses plain async functions and is unaffected. The benefit appears when implementing custom `FromRequestParts` extractors used within middleware:
```rust
// GOOD (edition 2024) - no #[async_trait] needed
impl<S> FromRequestParts<S> for RateLimitInfo
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let ip = parts.headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
Ok(RateLimitInfo { client_ip: ip.to_string() })
}
}
```
## FFI and `unsafe extern` (Edition 2024)
If middleware integrates with C libraries (e.g., custom TLS, hardware security modules), edition 2024 requires `unsafe extern`:
```rust
// BAD (edition 2024 — won't compile)
extern "C" {
fn custom_tls_init() -> i32;
}
// GOOD (edition 2024)
unsafe extern "C" {
fn custom_tls_init() -> i32;
}
```
Also, `#[no_mangle]` on exported FFI functions must become `#[unsafe(no_mangle)]`.
## Graceful Shutdown
```rust
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
async fn shutdown_signal() {
tokio::signal::ctrl_c().await.expect("failed to listen for ctrl+c");
tracing::info!("shutdown signal received");
}
```
## Review Questions
1. Are layers ordered correctly (especially auth before business logic)?
2. Is `tower-http` used for standard concerns (CORS, compression, tracing)?
3. Is request timeout configured for production?
4. Does custom middleware use `from_fn_with_state` for state access?
5. Is graceful shutdown implemented?
6. Are extractors used in middleware using native `async fn` instead of `#[async_trait]`?
7. Are FFI blocks in middleware written as `unsafe extern "C"` (edition 2024)?
8. Are `#[no_mangle]` attributes on exported functions written as `#[unsafe(no_mangle)]` (edition 2024)?
FILE:references/routing.md
# Routing
## Basic Routing
```rust
use axum::{routing::{get, post, put, delete}, Router};
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/{id}", get(get_user).put(update_user).delete(delete_user))
.with_state(state);
```
Note: axum 0.7.x uses `{id}` path syntax. Earlier versions used `:id`.
## Nested Routers
Organize routes by domain. Each module can define its own router.
```rust
// users.rs
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list).post(create))
.route("/{id}", get(show).put(update).delete(destroy))
}
// main.rs
let app = Router::new()
.nest("/api/users", users::router())
.nest("/api/orders", orders::router())
.fallback(handle_404)
.with_state(state);
```
### Nested Router State
Nested routers must use the same state type or a subset. Use `Router::with_state()` to provide different state to nested routers:
```rust
// Sub-router with different state
let admin_router = Router::new()
.route("/stats", get(admin_stats))
.with_state(admin_state); // different state type
let app = Router::new()
.nest("/admin", admin_router) // already has its state
.with_state(app_state); // main app state
```
## Fallback Handlers
```rust
async fn handle_404() -> impl IntoResponse {
(StatusCode::NOT_FOUND, Json(json!({"error": "not found"})))
}
let app = Router::new()
.route("/api/health", get(health))
.fallback(handle_404);
```
## Route Conflicts
Routes conflict when two patterns can match the same path. axum panics at startup when this happens.
```rust
// CONFLICT - both match /users/123
.route("/users/{id}", get(get_user))
.route("/users/{name}", get(get_user_by_name))
// SOLUTION - differentiate by prefix or use query params
.route("/users/by-id/{id}", get(get_user))
.route("/users/by-name/{name}", get(get_user_by_name))
```
## Method Routing
```rust
// Explicit per-method routing (preferred)
.route("/items", get(list_items).post(create_item))
// Method router for custom handling
use axum::routing::MethodRouter;
fn items_router() -> MethodRouter<AppState> {
get(list_items)
.post(create_item)
.options(preflight)
}
```
## LazyLock for Static Route Configuration (Edition 2024)
Static route tables or regex patterns previously initialized with `once_cell::sync::Lazy` or `lazy_static!` should use `std::sync::LazyLock` (stable since Rust 1.80):
```rust
// BAD (unnecessary dependency)
use once_cell::sync::Lazy;
static ROUTE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/api/v\d+/").unwrap());
// GOOD (std library, no extra dependency)
use std::sync::LazyLock;
static ROUTE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^/api/v\d+/").unwrap());
```
## RPIT Capture in Router Functions (Edition 2024)
Functions returning `Router` are unaffected (concrete type). But helper functions returning `impl IntoResponse` used as fallback handlers or utility responses may over-capture lifetimes in edition 2024:
```rust
// Edition 2024: captures 'a even though response is owned
fn not_found_body<'a>(path: &'a str) -> impl IntoResponse {
Json(json!({"error": format!("not found: {path}")}))
}
// Fix with precise capture if needed
fn not_found_body<'a>(path: &'a str) -> impl IntoResponse + use<> {
Json(json!({"error": format!("not found: {path}")}))
}
```
## Review Questions
1. Are routes organized by domain using nested routers?
2. Is there a fallback handler for unmatched routes?
3. Are route methods explicit (`.get()`, `.post()`)?
4. Are there any route conflicts (overlapping path patterns)?
5. Is `with_state` called with the correct state type?
6. Are static route patterns using `std::sync::LazyLock` instead of `once_cell`/`lazy_static`?
7. Do helper functions returning `impl IntoResponse` with lifetime params need `+ use<>` precise capture?
Zustand state management for React and vanilla JavaScript. Use when creating stores, using selectors, persisting state to localStorage, integrating devtools,...
---
name: zustand-state
description: Zustand state management for React and vanilla JavaScript. Use when creating stores, using selectors, persisting state to localStorage, integrating devtools, or managing global state without Redux complexity. Triggers on zustand, create(), createStore, useStore, persist, devtools, immer middleware.
---
# Zustand State Management
Minimal state management - no providers, minimal boilerplate.
## Quick Reference
```typescript
import { create } from 'zustand'
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
// In component - select only what you need
const bears = useBearStore((state) => state.bears)
const increase = useBearStore((state) => state.increase)
```
## State Updates
```typescript
// Flat updates (auto-merged at one level)
set({ bears: 5 })
set((state) => ({ bears: state.bears + 1 }))
// Nested objects (manual spread required)
set((state) => ({
nested: { ...state.nested, count: state.nested.count + 1 }
}))
// Replace entire state (no merge)
set({ bears: 0 }, true)
```
## Selectors & Performance
```typescript
// Good - subscribes only to bears
const bears = useBearStore((state) => state.bears)
// Bad - rerenders on any change
const state = useBearStore()
// Multiple values with useShallow (prevents rerenders with shallow comparison)
import { useShallow } from 'zustand/react/shallow'
const { bears, fish } = useBearStore(
useShallow((state) => ({ bears: state.bears, fish: state.fish }))
)
// Array destructuring also works
const [bears, fish] = useBearStore(
useShallow((state) => [state.bears, state.fish])
)
```
## Access Outside Components
```typescript
// Get current state (non-reactive)
const state = useBearStore.getState()
// Update state
useBearStore.setState({ bears: 5 })
// Subscribe to changes
const unsub = useBearStore.subscribe((state) => console.log(state))
unsub() // unsubscribe
```
## Vanilla Store (No React)
```typescript
import { createStore } from 'zustand/vanilla'
const store = createStore((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
store.getState().bears
store.setState({ bears: 10 })
store.subscribe((state) => console.log(state))
```
## Additional Documentation
- **Middleware**: See [references/middleware.md](references/middleware.md) for persist, devtools, immer
- **Patterns**: See [references/patterns.md](references/patterns.md) for slices, testing, best practices
- **TypeScript**: See [references/typescript.md](references/typescript.md) for advanced typing patterns
## Key Patterns
| Pattern | When to Use |
|---------|-------------|
| Single selector | One piece of state needed |
| `useShallow` | Multiple values, avoid rerenders |
| `getState()` | Outside React, event handlers |
| `subscribe()` | External systems, logging |
| Vanilla store | Non-React environments |
FILE:MIDDLEWARE.md
# Middleware
## Persist (localStorage)
```typescript
import { persist, createJSONStorage } from 'zustand/middleware'
const useStore = create<State>()(
persist(
(set) => ({
bears: 0,
increase: () => set((s) => ({ bears: s.bears + 1 })),
}),
{
name: 'bear-storage',
storage: createJSONStorage(() => localStorage),
}
)
)
// Partial persistence
const useStore = create(
persist(
(set) => ({ bears: 0, fish: 0 }),
{
name: 'storage',
partialize: (state) => ({ bears: state.bears }), // only persist bears
}
)
)
```
## DevTools
```typescript
import { devtools } from 'zustand/middleware'
const useStore = create<State>()(
devtools(
(set) => ({
bears: 0,
increase: () => set(
(s) => ({ bears: s.bears + 1 }),
undefined,
'bear/increase' // Action name for DevTools
),
}),
{ name: 'BearStore' }
)
)
```
## Immer (Mutable Updates)
```typescript
import { immer } from 'zustand/middleware/immer'
const useStore = create<State>()(
immer((set) => ({
nested: { count: 0 },
increment: () =>
set((state) => {
state.nested.count += 1 // mutate directly with immer
}),
}))
)
```
## Combine Middleware
```typescript
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
const useStore = create<State>()(
devtools(
persist(
immer((set) => ({
// store implementation
})),
{ name: 'storage' }
),
{ name: 'DevToolsName' }
)
)
```
## SubscribeWithSelector
```typescript
import { subscribeWithSelector } from 'zustand/middleware'
const useStore = create(
subscribeWithSelector((set) => ({ bears: 0, fish: 0 }))
)
// Subscribe to specific field changes
const unsub = useStore.subscribe(
(state) => state.bears,
(bears, prevBears) => console.log(bears, prevBears),
{ fireImmediately: true }
)
```
## Next.js / SSR Hydration
```typescript
import { useEffect, useState } from 'react'
const useHydration = () => {
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(useBearStore.persist.hasHydrated())
return useBearStore.persist.onFinishHydration(() => setHydrated(true))
}, [])
return hydrated
}
function Component() {
const hydrated = useHydration()
const bears = useBearStore((state) => state.bears)
if (!hydrated) return <div>Loading...</div>
return <div>{bears} bears</div>
}
```
## Async Persistence
```typescript
const useStore = create(
persist(
(set) => ({ data: null }),
{
name: 'async-storage',
storage: createJSONStorage(() => ({
getItem: async (name) => {
const value = await asyncStorage.getItem(name)
return value
},
setItem: async (name, value) => {
await asyncStorage.setItem(name, value)
},
removeItem: async (name) => {
await asyncStorage.removeItem(name)
},
})),
}
)
)
```
FILE:PATTERNS.md
# Patterns & Best Practices
## Slices Pattern (Store Composition)
```typescript
// fishSlice.ts
const createFishSlice = (set) => ({
fish: 0,
addFish: () => set((state) => ({ fish: state.fish + 1 })),
})
// bearSlice.ts
const createBearSlice = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
})
// Combined store
import { create } from 'zustand'
const useBoundStore = create((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
```
## TypeScript Slices
```typescript
import { StateCreator } from 'zustand'
interface BearSlice {
bears: number
addBear: () => void
}
interface FishSlice {
fish: number
addFish: () => void
}
const createBearSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
BearSlice
> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
})
const useBoundStore = create<BearSlice & FishSlice>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
```
## Testing
### Mock Store Setup (Vitest)
```typescript
// __mocks__/zustand.ts
import { act } from '@testing-library/react'
import type * as ZustandExportedTypes from 'zustand'
export * from 'zustand'
const { create: actualCreate } =
await vi.importActual<typeof ZustandExportedTypes>('zustand')
export const storeResetFns = new Set<() => void>()
export const create = (<T>(stateCreator) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => store.setState(initialState, true))
return store
}) as typeof actualCreate
afterEach(() => {
act(() => storeResetFns.forEach((fn) => fn()))
})
```
### Component Tests
```typescript
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('increments count', async () => {
const user = userEvent.setup()
render(<Counter />)
expect(await screen.findByText('0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /increment/i }))
expect(await screen.findByText('1')).toBeInTheDocument()
})
test('reads store directly', () => {
expect(useCountStore.getState().count).toBe(0)
})
```
## Reset Store
```typescript
const useStore = create<State & Actions>()((set, get, store) => ({
bears: 0,
fish: 0,
reset: () => set(store.getInitialState()),
}))
```
## Computed Values (Derived State)
```typescript
// Don't store computed values - derive in selectors
const totalFood = useBearStore((s) => s.bears * s.foodPerBear)
// Or in the component
const totalFood = bears * foodPerBear
```
## Transient Updates (No Rerender)
```typescript
function Component() {
const scratchRef = useRef(useScratchStore.getState().scratches)
useEffect(() =>
useScratchStore.subscribe(
(state) => { scratchRef.current = state.scratches }
), []
)
// Use scratchRef.current without causing rerenders
}
```
## Best Practices
### Single Store Per Domain
Use one store for each domain (user, cart, ui). Split large stores with slices.
### Colocate Actions
```typescript
// Good - actions in store
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
// Avoid - external actions
const increment = () => useStore.setState((s) => ({ count: s.count + 1 }))
```
### Selector Stability
```typescript
// Bad - creates new function every render
const action = useBearStore((state) => () => state.increase(1))
// Good - select function directly
const increase = useBearStore((state) => state.increase)
```
## Pitfalls to Avoid
### Don't Mutate State
```typescript
// Wrong
set((state) => {
state.count += 1 // Mutation!
return state
})
// Correct (without immer)
set((state) => ({ count: state.count + 1 }))
```
### Avoid Fetching Entire Store
```typescript
// Bad - rerenders on any change
const { bears, fish, cats } = useBearStore()
// Good - subscribe only to needed values
const bears = useBearStore((state) => state.bears)
```
### React Context (Dependency Injection)
```typescript
import { createContext, useContext, useRef } from 'react'
import { createStore, useStore } from 'zustand'
const StoreContext = createContext(null)
const StoreProvider = ({ children }) => {
const storeRef = useRef()
if (!storeRef.current) {
storeRef.current = createStore((set) => ({
bears: 0,
increase: () => set((s) => ({ bears: s.bears + 1 })),
}))
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
)
}
const useBearStore = (selector) => {
const store = useContext(StoreContext)
return useStore(store, selector)
}
```
FILE:TYPESCRIPT.md
# TypeScript Patterns
## Curried Create Syntax
```typescript
// Required for TypeScript - note the double parentheses
const useStore = create<State>()((set) => ({
// implementation
}))
// Wrong - type inference fails with middleware
create<State>((set) => ({ ... }))
// Correct
create<State>()((set) => ({ ... }))
```
## Separate State and Actions
```typescript
interface State {
bears: number
fish: number
}
interface Actions {
increase: () => void
reset: () => void
}
const useStore = create<State & Actions>()((set, get, store) => ({
bears: 0,
fish: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
reset: () => set(store.getInitialState()),
}))
```
## Extract Store Type
```typescript
import { type ExtractState } from 'zustand'
const useBearStore = create((set) => ({
bears: 3,
increase: (by: number) => set((s) => ({ bears: s.bears + by })),
}))
// Extract type for reuse
export type BearState = ExtractState<typeof useBearStore>
```
## Async Actions
```typescript
interface State {
data: Data | null
loading: boolean
}
interface Actions {
fetchData: () => Promise<void>
}
const useStore = create<State & Actions>((set) => ({
data: null,
loading: false,
fetchData: async () => {
set({ loading: true })
try {
const response = await fetch('/api/data')
const data = await response.json()
set({ data, loading: false })
} catch (error) {
set({ loading: false })
}
},
}))
```
## Typed Slices with StateCreator
```typescript
import { StateCreator } from 'zustand'
interface BearSlice {
bears: number
addBear: () => void
}
interface FishSlice {
fish: number
addFish: () => void
}
type BoundStore = BearSlice & FishSlice
const createBearSlice: StateCreator<
BoundStore, // Full store type
[], // Middleware applied before
[], // Middleware applied after
BearSlice // This slice's type
> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
})
const createFishSlice: StateCreator<
BoundStore,
[],
[],
FishSlice
> = (set) => ({
fish: 0,
addFish: () => set((state) => ({ fish: state.fish + 1 })),
})
const useBoundStore = create<BoundStore>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
```
## Middleware Type Parameters
```typescript
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface State {
count: number
increment: () => void
}
const useStore = create<State>()(
devtools(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{ name: 'count-storage' }
),
{ name: 'CountStore' }
)
)
```
## Typed Selectors
```typescript
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
// Selector is automatically typed
const bears = useBearStore((state) => state.bears) // number
const increase = useBearStore((state) => state.increase) // (by: number) => void
```
## Custom Equality Functions
```typescript
import { createWithEqualityFn } from 'zustand/traditional'
import { shallow } from 'zustand/shallow'
const useStore = createWithEqualityFn<State>()(
(set) => ({
// store implementation
}),
shallow // Use shallow comparison by default
)
```
## Vanilla Store with Types
```typescript
import { createStore } from 'zustand/vanilla'
interface CounterState {
count: number
increment: () => void
decrement: () => void
}
const counterStore = createStore<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}))
// Typed access
const count: number = counterStore.getState().count
```
FILE:references/middleware.md
# Middleware
## Contents
- [Persist (localStorage)](#persist-localstorage)
- [DevTools](#devtools)
- [Immer (Mutable Updates)](#immer-mutable-updates)
- [Combine Middleware](#combine-middleware)
- [SubscribeWithSelector](#subscribewithselector)
- [Next.js / SSR Hydration](#nextjs--ssr-hydration)
- [Async Persistence](#async-persistence)
---
## Persist (localStorage)
```typescript
import { persist, createJSONStorage } from 'zustand/middleware'
const useStore = create<State>()(
persist(
(set) => ({
bears: 0,
increase: () => set((s) => ({ bears: s.bears + 1 })),
}),
{
name: 'bear-storage',
storage: createJSONStorage(() => localStorage),
}
)
)
// Partial persistence
const useStore = create(
persist(
(set) => ({ bears: 0, fish: 0 }),
{
name: 'storage',
partialize: (state) => ({ bears: state.bears }), // only persist bears
}
)
)
```
## DevTools
```typescript
import { devtools } from 'zustand/middleware'
const useStore = create<State>()(
devtools(
(set) => ({
bears: 0,
increase: () => set(
(s) => ({ bears: s.bears + 1 }),
undefined,
'bear/increase' // Action name for DevTools
),
}),
{ name: 'BearStore' }
)
)
```
## Immer (Mutable Updates)
```typescript
import { immer } from 'zustand/middleware/immer'
const useStore = create<State>()(
immer((set) => ({
nested: { count: 0 },
increment: () =>
set((state) => {
state.nested.count += 1 // mutate directly with immer
}),
}))
)
```
## Combine Middleware
```typescript
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
const useStore = create<State>()(
devtools(
persist(
immer((set) => ({
// store implementation
})),
{ name: 'storage' }
),
{ name: 'DevToolsName' }
)
)
```
## SubscribeWithSelector
```typescript
import { subscribeWithSelector } from 'zustand/middleware'
const useStore = create(
subscribeWithSelector((set) => ({ bears: 0, fish: 0 }))
)
// Subscribe to specific field changes
const unsub = useStore.subscribe(
(state) => state.bears,
(bears, prevBears) => console.log(bears, prevBears),
{ fireImmediately: true }
)
```
## Next.js / SSR Hydration
```typescript
import { useEffect, useState } from 'react'
const useHydration = () => {
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(useBearStore.persist.hasHydrated())
return useBearStore.persist.onFinishHydration(() => setHydrated(true))
}, [])
return hydrated
}
function Component() {
const hydrated = useHydration()
const bears = useBearStore((state) => state.bears)
if (!hydrated) return <div>Loading...</div>
return <div>{bears} bears</div>
}
```
## Async Persistence
```typescript
const useStore = create(
persist(
(set) => ({ data: null }),
{
name: 'async-storage',
storage: createJSONStorage(() => ({
getItem: async (name) => {
const value = await asyncStorage.getItem(name)
return value
},
setItem: async (name, value) => {
await asyncStorage.setItem(name, value)
},
removeItem: async (name) => {
await asyncStorage.removeItem(name)
},
})),
}
)
)
```
FILE:references/patterns.md
# Patterns & Best Practices
## Contents
- [Slices Pattern (Store Composition)](#slices-pattern-store-composition)
- [TypeScript Slices](#typescript-slices)
- [Testing](#testing)
- [Mock Store Setup (Vitest)](#mock-store-setup-vitest)
- [Component Tests](#component-tests)
- [Reset Store](#reset-store)
- [Computed Values (Derived State)](#computed-values-derived-state)
- [Transient Updates (No Rerender)](#transient-updates-no-rerender)
- [Best Practices](#best-practices)
- [Single Store Per Domain](#single-store-per-domain)
- [Colocate Actions](#colocate-actions)
- [Selector Stability](#selector-stability)
- [Pitfalls to Avoid](#pitfalls-to-avoid)
- [Don't Mutate State](#dont-mutate-state)
- [Avoid Fetching Entire Store](#avoid-fetching-entire-store)
- [React Context (Dependency Injection)](#react-context-dependency-injection)
---
## Slices Pattern (Store Composition)
```typescript
// fishSlice.ts
const createFishSlice = (set) => ({
fish: 0,
addFish: () => set((state) => ({ fish: state.fish + 1 })),
})
// bearSlice.ts
const createBearSlice = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
})
// Combined store
import { create } from 'zustand'
const useBoundStore = create((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
```
## TypeScript Slices
```typescript
import { StateCreator } from 'zustand'
interface BearSlice {
bears: number
addBear: () => void
}
interface FishSlice {
fish: number
addFish: () => void
}
const createBearSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
BearSlice
> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
})
const useBoundStore = create<BearSlice & FishSlice>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
```
## Testing
### Mock Store Setup (Vitest)
```typescript
// __mocks__/zustand.ts
import { act } from '@testing-library/react'
import type * as ZustandExportedTypes from 'zustand'
export * from 'zustand'
const { create: actualCreate } =
await vi.importActual<typeof ZustandExportedTypes>('zustand')
export const storeResetFns = new Set<() => void>()
export const create = (<T>(stateCreator) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => store.setState(initialState, true))
return store
}) as typeof actualCreate
afterEach(() => {
act(() => storeResetFns.forEach((fn) => fn()))
})
```
### Component Tests
```typescript
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('increments count', async () => {
const user = userEvent.setup()
render(<Counter />)
expect(await screen.findByText('0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /increment/i }))
expect(await screen.findByText('1')).toBeInTheDocument()
})
test('reads store directly', () => {
expect(useCountStore.getState().count).toBe(0)
})
```
## Reset Store
```typescript
const useStore = create<State & Actions>()((set, get, store) => ({
bears: 0,
fish: 0,
reset: () => set(store.getInitialState()),
}))
```
## Computed Values (Derived State)
```typescript
// Don't store computed values - derive in selectors
const totalFood = useBearStore((s) => s.bears * s.foodPerBear)
// Or in the component
const totalFood = bears * foodPerBear
```
## Transient Updates (No Rerender)
```typescript
function Component() {
const scratchRef = useRef(useScratchStore.getState().scratches)
useEffect(() =>
useScratchStore.subscribe(
(state) => { scratchRef.current = state.scratches }
), []
)
// Use scratchRef.current without causing rerenders
}
```
## Best Practices
### Single Store Per Domain
Use one store for each domain (user, cart, ui). Split large stores with slices.
### Colocate Actions
```typescript
// Good - actions in store
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
// Avoid - external actions
const increment = () => useStore.setState((s) => ({ count: s.count + 1 }))
```
### Selector Stability
```typescript
// Bad - creates new function every render
const action = useBearStore((state) => () => state.increase(1))
// Good - select function directly
const increase = useBearStore((state) => state.increase)
```
## Pitfalls to Avoid
### Don't Mutate State
```typescript
// Wrong
set((state) => {
state.count += 1 // Mutation!
return state
})
// Correct (without immer)
set((state) => ({ count: state.count + 1 }))
```
### Avoid Fetching Entire Store
```typescript
// Bad - rerenders on any change
const { bears, fish, cats } = useBearStore()
// Good - subscribe only to needed values
const bears = useBearStore((state) => state.bears)
```
### React Context (Dependency Injection)
```typescript
import { createContext, useContext, useRef } from 'react'
import { createStore, useStore } from 'zustand'
const StoreContext = createContext(null)
const StoreProvider = ({ children }) => {
const storeRef = useRef()
if (!storeRef.current) {
storeRef.current = createStore((set) => ({
bears: 0,
increase: () => set((s) => ({ bears: s.bears + 1 })),
}))
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
)
}
const useBearStore = (selector) => {
const store = useContext(StoreContext)
return useStore(store, selector)
}
```
FILE:references/typescript.md
# TypeScript Patterns
## Contents
- [Curried Create Syntax](#curried-create-syntax)
- [Separate State and Actions](#separate-state-and-actions)
- [Extract Store Type](#extract-store-type)
- [Async Actions](#async-actions)
- [Typed Slices with StateCreator](#typed-slices-with-statecreator)
- [Middleware Type Parameters](#middleware-type-parameters)
- [Typed Selectors](#typed-selectors)
- [Custom Equality Functions](#custom-equality-functions)
- [Vanilla Store with Types](#vanilla-store-with-types)
---
## Curried Create Syntax
```typescript
// Required for TypeScript - note the double parentheses
const useStore = create<State>()((set) => ({
// implementation
}))
// Wrong - type inference fails with middleware
create<State>((set) => ({ ... }))
// Correct
create<State>()((set) => ({ ... }))
```
## Separate State and Actions
```typescript
interface State {
bears: number
fish: number
}
interface Actions {
increase: () => void
reset: () => void
}
const useStore = create<State & Actions>()((set, get, store) => ({
bears: 0,
fish: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
reset: () => set(store.getInitialState()),
}))
```
## Extract Store Type
```typescript
import { type ExtractState } from 'zustand'
const useBearStore = create((set) => ({
bears: 3,
increase: (by: number) => set((s) => ({ bears: s.bears + by })),
}))
// Extract type for reuse
export type BearState = ExtractState<typeof useBearStore>
```
## Async Actions
```typescript
interface State {
data: Data | null
loading: boolean
}
interface Actions {
fetchData: () => Promise<void>
}
const useStore = create<State & Actions>((set) => ({
data: null,
loading: false,
fetchData: async () => {
set({ loading: true })
try {
const response = await fetch('/api/data')
const data = await response.json()
set({ data, loading: false })
} catch (error) {
set({ loading: false })
}
},
}))
```
## Typed Slices with StateCreator
```typescript
import { StateCreator } from 'zustand'
interface BearSlice {
bears: number
addBear: () => void
}
interface FishSlice {
fish: number
addFish: () => void
}
type BoundStore = BearSlice & FishSlice
const createBearSlice: StateCreator<
BoundStore, // Full store type
[], // Middleware applied before
[], // Middleware applied after
BearSlice // This slice's type
> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
})
const createFishSlice: StateCreator<
BoundStore,
[],
[],
FishSlice
> = (set) => ({
fish: 0,
addFish: () => set((state) => ({ fish: state.fish + 1 })),
})
const useBoundStore = create<BoundStore>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
```
## Middleware Type Parameters
```typescript
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface State {
count: number
increment: () => void
}
const useStore = create<State>()(
devtools(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{ name: 'count-storage' }
),
{ name: 'CountStore' }
)
)
```
## Typed Selectors
```typescript
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
// Selector is automatically typed
const bears = useBearStore((state) => state.bears) // number
const increase = useBearStore((state) => state.increase) // (by: number) => void
```
## Custom Equality Functions
```typescript
import { createWithEqualityFn } from 'zustand/traditional'
import { shallow } from 'zustand/shallow'
const useStore = createWithEqualityFn<State>()(
(set) => ({
// store implementation
}),
shallow // Use shallow comparison by default
)
// Note: For most cases, use useShallow instead
// useShallow is imported from 'zustand/react/shallow' or 'zustand/shallow'
// The former is React-specific, the latter includes both shallow and useShallow
```
## Vanilla Store with Types
```typescript
import { createStore } from 'zustand/vanilla'
interface CounterState {
count: number
increment: () => void
decrement: () => void
}
const counterStore = createStore<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}))
// Typed access
const count: number = counterStore.getState().count
```
Vitest testing framework patterns and best practices. Use when writing unit tests, integration tests, configuring vitest.config, mocking with vi.mock/vi.fn,...
---
name: vitest-testing
description: Vitest testing framework patterns and best practices. Use when writing unit tests, integration tests, configuring vitest.config, mocking with vi.mock/vi.fn, using snapshots, or setting up test coverage. Triggers on describe, it, expect, vi.mock, vi.fn, beforeEach, afterEach, vitest.
---
# Vitest Best Practices
## Quick Reference
```ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
describe('feature name', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should do something specific', () => {
expect(actual).toBe(expected)
})
it.todo('planned test')
it.skip('temporarily disabled')
it.only('run only this during dev')
})
```
## Common Assertions
```ts
// Equality
expect(value).toBe(42) // Strict (===)
expect(obj).toEqual({ a: 1 }) // Deep equality
expect(obj).toStrictEqual({ a: 1 }) // Strict deep (checks types)
// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
// Numbers
expect(0.1 + 0.2).toBeCloseTo(0.3)
expect(value).toBeGreaterThan(5)
// Strings/Arrays
expect(str).toMatch(/pattern/)
expect(str).toContain('substring')
expect(array).toContain(item)
expect(array).toHaveLength(3)
// Objects
expect(obj).toHaveProperty('key')
expect(obj).toHaveProperty('nested.key', 'value')
expect(obj).toMatchObject({ subset: 'of properties' })
// Exceptions
expect(() => fn()).toThrow()
expect(() => fn()).toThrow('error message')
expect(() => fn()).toThrow(/pattern/)
```
## Async Testing
```ts
// Async/await (preferred)
it('fetches data', async () => {
const data = await fetchData()
expect(data).toEqual({ id: 1 })
})
// Promise matchers - ALWAYS await these
await expect(fetchData()).resolves.toEqual({ id: 1 })
await expect(fetchData()).rejects.toThrow('Error')
// Wrong - creates false positive
expect(promise).resolves.toBe(value) // Missing await!
```
## Quick Mock Reference
```ts
const mockFn = vi.fn()
mockFn.mockReturnValue(42)
mockFn.mockResolvedValue({ data: 'value' })
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
expect(mockFn).toHaveBeenCalledTimes(2)
```
## Additional Documentation
- **Mocking**: See [references/mocking.md](references/mocking.md) for module mocking, spying, cleanup
- **Configuration**: See [references/config.md](references/config.md) for vitest.config, setup files, coverage
- **Patterns**: See [references/patterns.md](references/patterns.md) for timers, snapshots, anti-patterns
## Test Methods Quick Reference
| Method | Purpose |
|--------|---------|
| `it()` / `test()` | Define test |
| `describe()` | Group tests |
| `beforeEach()` / `afterEach()` | Per-test hooks |
| `beforeAll()` / `afterAll()` | Per-suite hooks |
| `.skip` | Skip test/suite |
| `.only` | Run only this |
| `.todo` | Placeholder |
| `.concurrent` | Parallel execution |
| `.each([...])` | Parameterized tests |
FILE:CONFIG.md
# Configuration
## Basic Config
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true, // Use global test APIs (describe, it, expect)
environment: 'node', // 'node' | 'jsdom' | 'happy-dom'
setupFiles: './test/setup.ts',
coverage: {
provider: 'v8', // 'v8' | 'istanbul'
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.ts', '**/node_modules/**']
},
include: ['**/*.test.ts'],
exclude: ['node_modules', 'dist'],
testTimeout: 10000,
}
})
```
## Global Setup
```ts
// test/setup.ts
import { beforeEach, afterEach, vi } from 'vitest'
// Global beforeEach/afterEach
beforeEach(() => {
vi.clearAllMocks()
})
// Extend matchers
import { expect } from 'vitest'
expect.extend({
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling
return {
pass,
message: () => `expected received to be within floor-ceiling`
}
}
})
```
## DOM Testing
```ts
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: './test/setup.ts'
}
})
// Tests
it('updates DOM', () => {
document.body.innerHTML = '<div id="app"></div>'
const app = document.querySelector('#app')
expect(app).toBeTruthy()
expect(app?.textContent).toBe('')
})
```
## Concurrent Tests
```ts
// Run tests in parallel
describe.concurrent('suite', () => {
it('test 1', async () => { /* ... */ })
it('test 2', async () => { /* ... */ })
})
// Individual concurrent tests
it.concurrent('test 1', async () => { /* ... */ })
it.concurrent('test 2', async () => { /* ... */ })
// Use local expect for concurrent tests
it.concurrent('test', async ({ expect }) => {
expect(value).toBe(1)
})
```
## Test Isolation
```ts
export default defineConfig({
test: {
isolate: false, // Share environment between tests (faster)
pool: 'threads', // 'threads' | 'forks' | 'vmThreads'
poolOptions: {
threads: {
singleThread: true // Run tests in single thread
}
}
}
})
```
## Type Testing
```ts
import { expectTypeOf, assertType } from 'vitest'
// Compile-time type assertions
expectTypeOf({ a: 1 }).toEqualTypeOf<{ a: number }>()
expectTypeOf('string').toBeString()
expectTypeOf(promise).resolves.toBeNumber()
assertType<string>('hello') // Type guard
```
## Environment Variables
```ts
// vitest.config.ts
export default defineConfig({
test: {
env: {
TEST_VAR: 'test-value'
}
}
})
// Or use .env.test file
// Tests can access via process.env.TEST_VAR
```
## Coverage Configuration
```ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
exclude: [
'node_modules',
'test',
'**/*.d.ts',
'**/*.test.ts',
'**/types.ts'
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
}
})
```
FILE:MOCKING.md
# Mocking Patterns
## Module Mocking
```ts
// Mock entire module (hoisted automatically)
vi.mock('./module', () => ({
namedExport: vi.fn(() => 'mocked'),
default: vi.fn()
}))
// Partial mock with importActual
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils')
return {
...actual,
specificFunction: vi.fn()
}
})
// Access mocked module
import { specificFunction } from './utils'
vi.mocked(specificFunction).mockReturnValue('value')
// Mock with spy (keeps implementation)
vi.mock('./calculator', { spy: true })
```
## Function Mocking
```ts
// Create mock function
const mockFn = vi.fn()
const mockFnWithImpl = vi.fn((x) => x * 2)
// Mock return values
mockFn.mockReturnValue(42)
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2)
// Mock async returns
mockFn.mockResolvedValue({ data: 'value' })
mockFn.mockRejectedValue(new Error('failed'))
// Mock implementation
mockFn.mockImplementation((arg) => arg + 1)
mockFn.mockImplementationOnce(() => 'once')
```
## Mock Assertions
```ts
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
expect(mockFn).toHaveBeenLastCalledWith('arg')
expect(mockFn).toHaveReturnedWith(42)
// Access mock state
mockFn.mock.calls // [['arg1'], ['arg2']]
mockFn.mock.results // [{ type: 'return', value: 42 }]
mockFn.mock.lastCall // ['arg2']
```
## Spying
```ts
// Spy on object methods
const obj = { method: () => 'real' }
const spy = vi.spyOn(obj, 'method')
// Spy with custom implementation
vi.spyOn(obj, 'method').mockImplementation(() => 'mocked')
// Spy on getters/setters
vi.spyOn(obj, 'property', 'get').mockReturnValue('value')
vi.spyOn(obj, 'property', 'set')
// Restore original
spy.mockRestore()
```
## Mock Cleanup
```ts
import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
vi.clearAllMocks() // Clear mock history
vi.resetAllMocks() // Clear history + reset implementations
vi.restoreAllMocks() // Restore original implementations (spies)
})
// Or configure in vitest.config.ts
export default defineConfig({
test: {
clearMocks: true, // Auto-clear before each test
mockReset: true, // Auto-reset before each test
restoreMocks: true, // Auto-restore before each test
}
})
```
## Mock Methods Quick Reference
| Method | Purpose |
|--------|---------|
| `vi.fn()` | Create mock function |
| `vi.spyOn()` | Spy on method |
| `vi.mock()` | Mock module |
| `vi.importActual()` | Import real module |
| `vi.mocked()` | Type helper for mocks |
| `vi.clearAllMocks()` | Clear call history |
| `vi.resetAllMocks()` | Reset implementations |
| `vi.restoreAllMocks()` | Restore originals |
FILE:PATTERNS.md
# Common Patterns
## Fake Timers
```ts
import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('executes after timeout', () => {
const callback = vi.fn()
setTimeout(callback, 1000)
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalled()
})
// Timer methods
vi.runAllTimers()
vi.runOnlyPendingTimers()
vi.advanceTimersByTime(1000)
vi.advanceTimersToNextTimer()
vi.setSystemTime(new Date('2024-01-01'))
```
## Waiting Utilities
```ts
// Wait for condition
await vi.waitFor(() => {
expect(element).toBeTruthy()
}, { timeout: 1000, interval: 50 })
// Wait until truthy
const element = await vi.waitUntil(
() => document.querySelector('.loaded'),
{ timeout: 1000 }
)
```
## Snapshots
```ts
// Basic snapshot
it('matches snapshot', () => {
const data = { foo: 'bar' }
expect(data).toMatchSnapshot()
})
// Inline snapshot (updates test file)
it('matches inline snapshot', () => {
expect(render()).toMatchInlineSnapshot(`
<div>
<h1>Title</h1>
</div>
`)
})
// File snapshot
it('matches file snapshot', async () => {
const html = renderHTML()
await expect(html).toMatchFileSnapshot('./expected.html')
})
// Property matchers for dynamic values
expect(data).toMatchSnapshot({
id: expect.any(Number),
timestamp: expect.any(Date),
uuid: expect.stringMatching(/^[a-f0-9-]+$/)
})
// Update snapshots: vitest -u
```
## Testing Errors
```ts
// Sync errors
expect(() => throwError()).toThrow()
expect(() => throwError()).toThrow('specific message')
expect(() => throwError()).toThrow(/pattern/)
expect(() => throwError()).toThrowError(CustomError)
// Async errors
await expect(asyncThrow()).rejects.toThrow()
await expect(asyncThrow()).rejects.toThrow('message')
```
## Anti-Patterns to Avoid
```ts
// Don't nest describes excessively
describe('A', () => {
describe('B', () => {
describe('C', () => {
describe('D', () => { /* too nested */ })
})
})
})
// Don't forget await on async expects
expect(promise).resolves.toBe(value) // Wrong - false positive!
await expect(promise).resolves.toBe(value) // Correct
// Don't test implementation details
expect(component.state.internalFlag).toBe(true) // Brittle
// Don't share state between tests
let sharedVariable
it('test 1', () => { sharedVariable = 'value' })
it('test 2', () => { expect(sharedVariable).toBe('value') }) // Flaky!
// Don't vi.mock inside tests (hoisting issues)
it('test', () => {
vi.mock('./module') // Won't work!
})
```
## Best Practices
```ts
// Keep describes shallow
describe('UserService', () => {
it('creates user with valid data')
it('throws on invalid email')
})
// Always await async expects
await expect(promise).resolves.toBe(value)
// Test behavior, not implementation
expect(getUserName()).toBe('John Doe')
// Use beforeEach for isolation
beforeEach(() => {
state = createFreshState()
})
// vi.mock at top level (before imports)
vi.mock('./module')
import { fn } from './module'
```
## Environment Methods
| Method | Purpose |
|--------|---------|
| `vi.useFakeTimers()` | Enable fake timers |
| `vi.useRealTimers()` | Restore real timers |
| `vi.setSystemTime()` | Mock system time |
| `vi.stubGlobal()` | Mock global variable |
| `vi.stubEnv()` | Mock environment variable |
FILE:references/config.md
# Configuration
## Contents
- [Basic Config](#basic-config)
- [Global Setup](#global-setup)
- [DOM Testing](#dom-testing)
- [Concurrent Tests](#concurrent-tests)
- [Test Isolation](#test-isolation)
- [Type Testing](#type-testing)
- [Environment Variables](#environment-variables)
- [Coverage Configuration](#coverage-configuration)
---
## Basic Config
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true, // Use global test APIs (describe, it, expect)
environment: 'node', // 'node' | 'jsdom' | 'happy-dom'
setupFiles: './test/setup.ts',
coverage: {
provider: 'v8', // 'v8' | 'istanbul'
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.ts', '**/node_modules/**']
},
include: ['**/*.test.ts'],
exclude: ['node_modules', 'dist'],
testTimeout: 10000,
}
})
```
## Global Setup
```ts
// test/setup.ts
import { beforeEach, afterEach, vi } from 'vitest'
// Global beforeEach/afterEach
beforeEach(() => {
vi.clearAllMocks()
})
// Extend matchers
import { expect } from 'vitest'
expect.extend({
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling
return {
pass,
message: () => `expected received to be within floor-ceiling`
}
}
})
```
## DOM Testing
```ts
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: './test/setup.ts'
}
})
// Tests
it('updates DOM', () => {
document.body.innerHTML = '<div id="app"></div>'
const app = document.querySelector('#app')
expect(app).toBeTruthy()
expect(app?.textContent).toBe('')
})
```
## Concurrent Tests
```ts
// Run tests in parallel
describe.concurrent('suite', () => {
it('test 1', async () => { /* ... */ })
it('test 2', async () => { /* ... */ })
})
// Individual concurrent tests
it.concurrent('test 1', async () => { /* ... */ })
it.concurrent('test 2', async () => { /* ... */ })
// Use local expect for concurrent tests
it.concurrent('test', async ({ expect }) => {
expect(value).toBe(1)
})
```
## Test Isolation
```ts
export default defineConfig({
test: {
isolate: false, // Share environment between tests (faster)
pool: 'threads', // 'threads' | 'forks' | 'vmThreads'
poolOptions: {
threads: {
singleThread: true // Run tests in single thread
}
}
}
})
```
## Type Testing
```ts
import { expectTypeOf, assertType } from 'vitest'
// Compile-time type assertions
expectTypeOf({ a: 1 }).toEqualTypeOf<{ a: number }>()
expectTypeOf('string').toBeString()
expectTypeOf(promise).resolves.toBeNumber()
assertType<string>('hello') // Type guard
```
## Environment Variables
```ts
// vitest.config.ts
export default defineConfig({
test: {
env: {
TEST_VAR: 'test-value'
}
}
})
// Or use .env.test file
// Tests can access via process.env.TEST_VAR
```
## Coverage Configuration
```ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
exclude: [
'node_modules',
'test',
'**/*.d.ts',
'**/*.test.ts',
'**/types.ts'
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
}
})
```
FILE:references/mocking.md
# Mocking Patterns
## Contents
- [Module Mocking](#module-mocking)
- [Function Mocking](#function-mocking)
- [Mock Assertions](#mock-assertions)
- [Spying](#spying)
- [Mock Cleanup](#mock-cleanup)
- [Mock Methods Quick Reference](#mock-methods-quick-reference)
---
## Module Mocking
```ts
// Mock entire module (hoisted automatically)
vi.mock('./module', () => ({
namedExport: vi.fn(() => 'mocked'),
default: vi.fn()
}))
// Partial mock with importActual (two ways)
// Option 1: Use vi.importActual directly
vi.mock('./utils', async () => {
const actual = await vi.importActual<typeof import('./utils')>('./utils')
return {
...actual,
specificFunction: vi.fn()
}
})
// Option 2: Use the importOriginal helper parameter
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils')>()
return {
...actual,
specificFunction: vi.fn()
}
})
// Access mocked module
import { specificFunction } from './utils'
vi.mocked(specificFunction).mockReturnValue('value')
// Mock with spy (keeps implementation)
vi.mock('./calculator', { spy: true })
```
## Function Mocking
```ts
// Create mock function
const mockFn = vi.fn()
const mockFnWithImpl = vi.fn((x) => x * 2)
// Mock return values
mockFn.mockReturnValue(42)
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2)
// Mock async returns
mockFn.mockResolvedValue({ data: 'value' })
mockFn.mockRejectedValue(new Error('failed'))
// Mock implementation
mockFn.mockImplementation((arg) => arg + 1)
mockFn.mockImplementationOnce(() => 'once')
```
## Mock Assertions
```ts
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
expect(mockFn).toHaveBeenLastCalledWith('arg')
expect(mockFn).toHaveReturnedWith(42)
// Access mock state
mockFn.mock.calls // [['arg1'], ['arg2']]
mockFn.mock.results // [{ type: 'return', value: 42 }]
mockFn.mock.lastCall // ['arg2']
```
## Spying
```ts
// Spy on object methods
const obj = { method: () => 'real' }
const spy = vi.spyOn(obj, 'method')
// Spy with custom implementation
vi.spyOn(obj, 'method').mockImplementation(() => 'mocked')
// Spy on getters/setters
vi.spyOn(obj, 'property', 'get').mockReturnValue('value')
vi.spyOn(obj, 'property', 'set')
// Restore original
spy.mockRestore()
```
## Mock Cleanup
```ts
import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
vi.clearAllMocks() // Clear mock history
vi.resetAllMocks() // Clear history + reset implementations
vi.restoreAllMocks() // Restore original implementations (spies)
})
// Or configure in vitest.config.ts
export default defineConfig({
test: {
clearMocks: true, // Auto-clear before each test
mockReset: true, // Auto-reset before each test
restoreMocks: true, // Auto-restore before each test
}
})
```
## Mock Methods Quick Reference
| Method | Purpose |
|--------|---------|
| `vi.fn()` | Create mock function |
| `vi.spyOn()` | Spy on method |
| `vi.mock()` | Mock module |
| `vi.importActual()` | Import real module |
| `vi.mocked()` | Type helper for mocks |
| `vi.clearAllMocks()` | Clear call history |
| `vi.resetAllMocks()` | Reset implementations |
| `vi.restoreAllMocks()` | Restore originals |
FILE:references/patterns.md
# Common Patterns
## Contents
- [Fake Timers](#fake-timers)
- [Waiting Utilities](#waiting-utilities)
- [Snapshots](#snapshots)
- [Testing Errors](#testing-errors)
- [Anti-Patterns to Avoid](#anti-patterns-to-avoid)
- [Best Practices](#best-practices)
- [Environment Methods](#environment-methods)
---
## Fake Timers
```ts
import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('executes after timeout', () => {
const callback = vi.fn()
setTimeout(callback, 1000)
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalled()
})
// Timer methods
vi.runAllTimers()
vi.runOnlyPendingTimers()
vi.advanceTimersByTime(1000)
vi.advanceTimersToNextTimer()
vi.setSystemTime(new Date('2024-01-01'))
```
## Waiting Utilities
```ts
// Wait for condition
await vi.waitFor(() => {
expect(element).toBeTruthy()
}, { timeout: 1000, interval: 50 })
// Wait until truthy
const element = await vi.waitUntil(
() => document.querySelector('.loaded'),
{ timeout: 1000 }
)
```
## Snapshots
```ts
// Basic snapshot
it('matches snapshot', () => {
const data = { foo: 'bar' }
expect(data).toMatchSnapshot()
})
// Inline snapshot (updates test file)
it('matches inline snapshot', () => {
expect(render()).toMatchInlineSnapshot(`
<div>
<h1>Title</h1>
</div>
`)
})
// File snapshot
it('matches file snapshot', async () => {
const html = renderHTML()
await expect(html).toMatchFileSnapshot('./expected.html')
})
// Property matchers for dynamic values
expect(data).toMatchSnapshot({
id: expect.any(Number),
timestamp: expect.any(Date),
uuid: expect.stringMatching(/^[a-f0-9-]+$/)
})
// Update snapshots: vitest -u
```
## Testing Errors
```ts
// Sync errors
expect(() => throwError()).toThrow()
expect(() => throwError()).toThrow('specific message')
expect(() => throwError()).toThrow(/pattern/)
expect(() => throwError()).toThrowError(CustomError)
// Async errors
await expect(asyncThrow()).rejects.toThrow()
await expect(asyncThrow()).rejects.toThrow('message')
```
## Anti-Patterns to Avoid
```ts
// Don't nest describes excessively
describe('A', () => {
describe('B', () => {
describe('C', () => {
describe('D', () => { /* too nested */ })
})
})
})
// Don't forget await on async expects
expect(promise).resolves.toBe(value) // Wrong - false positive!
await expect(promise).resolves.toBe(value) // Correct
// Don't test implementation details
expect(component.state.internalFlag).toBe(true) // Brittle
// Don't share state between tests
let sharedVariable
it('test 1', () => { sharedVariable = 'value' })
it('test 2', () => { expect(sharedVariable).toBe('value') }) // Flaky!
// Don't vi.mock inside tests (hoisting issues)
it('test', () => {
vi.mock('./module') // Won't work!
})
```
## Best Practices
```ts
// Keep describes shallow
describe('UserService', () => {
it('creates user with valid data')
it('throws on invalid email')
})
// Always await async expects
await expect(promise).resolves.toBe(value)
// Test behavior, not implementation
expect(getUserName()).toBe('John Doe')
// Use beforeEach for isolation
beforeEach(() => {
state = createFreshState()
})
// vi.mock at top level (before imports)
vi.mock('./module')
import { fn } from './module'
```
## Environment Methods
| Method | Purpose |
|--------|---------|
| `vi.useFakeTimers()` | Enable fake timers |
| `vi.useRealTimers()` | Restore real timers |
| `vi.setSystemTime()` | Mock system time |
| `vi.stubGlobal()` | Mock global variable |
| `vi.stubEnv()` | Mock environment variable |
Tailwind CSS v4 with CSS-first configuration and design tokens. Use when setting up Tailwind v4, defining theme variables, using OKLCH colors, or configuring...
---
name: tailwind-v4
description: Tailwind CSS v4 with CSS-first configuration and design tokens. Use when setting up Tailwind v4, defining theme variables, using OKLCH colors, or configuring dark mode. Triggers on @theme, @tailwindcss/vite, oklch, CSS variables, --color-, tailwind v4.
---
# Tailwind CSS v4 Best Practices
## Quick Reference
**Vite Plugin Setup**:
```ts
// vite.config.ts
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss()],
});
```
**CSS Entry Point**:
```css
/* src/index.css */
@import 'tailwindcss';
```
**@theme Inline Directive**:
```css
@theme inline {
--color-primary: oklch(60% 0.24 262);
--color-surface: oklch(98% 0.002 247);
}
```
## Key Differences from v3
| Feature | v3 | v4 |
|---------|----|----|
| Configuration | tailwind.config.js | @theme in CSS |
| Build Tool | PostCSS plugin | @tailwindcss/vite |
| Colors | rgb() / hsl() | oklch() (default) |
| Theme Extension | extend: {} in JS | CSS variables |
| Dark Mode | darkMode config option | CSS variants |
## @theme Directive Modes
### default (standard mode)
Generates CSS variables that can be referenced elsewhere:
```css
@theme {
--color-brand: oklch(60% 0.24 262);
}
/* Generates: :root { --color-brand: oklch(...); } */
/* Usage: text-brand → color: var(--color-brand) */
```
**Note**: You can also use `@theme default` explicitly to mark theme values that can be overridden by non-default @theme declarations.
### inline
Inlines values directly without CSS variables (better performance):
```css
@theme inline {
--color-brand: oklch(60% 0.24 262);
}
/* Usage: text-brand → color: oklch(60% 0.24 262) */
```
### reference
Inlines values as fallbacks without emitting CSS variables:
```css
@theme reference {
--color-internal: oklch(50% 0.1 180);
}
/* No :root variable, but utilities use fallback */
/* Usage: bg-internal → background-color: var(--color-internal, oklch(50% 0.1 180)) */
```
## OKLCH Color Format
OKLCH provides perceptually uniform colors with better consistency across hues:
```css
oklch(L% C H)
```
- **L (Lightness)**: 0% (black) to 100% (white)
- **C (Chroma)**: 0 (gray) to ~0.4 (vibrant)
- **H (Hue)**: 0-360 degrees (red → yellow → green → blue → magenta)
**Examples**:
```css
--color-sky-500: oklch(68.5% 0.169 237.323); /* Bright blue */
--color-red-600: oklch(57.7% 0.245 27.325); /* Vibrant red */
--color-zinc-900: oklch(21% 0.006 285.885); /* Near-black gray */
```
## CSS Variable Naming
Tailwind v4 uses double-dash CSS variable naming conventions:
```css
@theme {
/* Colors: --color-{name}-{shade} */
--color-primary-500: oklch(60% 0.24 262);
/* Spacing: --spacing multiplier */
--spacing: 0.25rem; /* Base unit for spacing scale */
/* Fonts: --font-{family} */
--font-display: 'Inter Variable', system-ui, sans-serif;
/* Breakpoints: --breakpoint-{size} */
--breakpoint-lg: 64rem;
/* Custom animations: --animate-{name} */
--animate-fade-in: fade-in 0.3s ease-out;
}
```
## No Config Files Needed
Tailwind v4 eliminates configuration files:
- **No `tailwind.config.js`** - Use @theme in CSS instead
- **No `postcss.config.js`** - Use @tailwindcss/vite plugin
- **TypeScript support** - Add `@types/node` for path resolution
```json
{
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.0.0",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0"
}
}
```
## Progressive Disclosure
- **Setup & Installation**: See [references/setup.md](references/setup.md) for Vite plugin configuration, package setup, TypeScript config
- **Theming & Design Tokens**: See [references/theming.md](references/theming.md) for @theme modes, color palettes, custom fonts, animations
- **Dark Mode Strategies**: See [references/dark-mode.md](references/dark-mode.md) for media queries, class-based, attribute-based approaches
## Decision Guide
### When to use @theme inline vs default
**Use `@theme inline`**:
- Better performance (no CSS variable overhead)
- Static color values that won't change
- Animation keyframes with multiple values
- Utilities that need direct value inlining
**Use `@theme` (default)**:
- Dynamic theming with JavaScript
- CSS variable references in custom CSS
- Values that change based on context
- Better debugging (inspect CSS variables in DevTools)
### When to use @theme reference
**Use `@theme reference`**:
- Provide fallback values without CSS variable overhead
- Values that should work even if variable isn't defined
- Reducing :root bloat while maintaining utility support
- Combining with inline for direct value substitution
## Common Patterns
### Two-Tier Variable System
Semantic variables that map to design tokens:
```css
@theme {
/* Design tokens (OKLCH colors) */
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-slate-800: oklch(27.9% 0.041 260.031);
/* Semantic mappings */
--color-primary: var(--color-blue-600);
--color-surface: var(--color-slate-800);
}
/* Usage: bg-primary, bg-surface */
```
### Custom Font Configuration
```css
@theme {
--font-display: 'Inter Variable', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-display--font-variation-settings: 'wght' 400;
--font-display--font-feature-settings: 'cv02', 'cv03', 'cv04';
}
/* Usage: font-display, font-mono */
```
### Animation Keyframes
```css
@theme inline {
--animate-beacon: beacon 2s ease-in-out infinite;
@keyframes beacon {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.05);
}
}
}
/* Usage: animate-beacon */
```
FILE:references/dark-mode.md
# Dark Mode Strategies
## Contents
- [Media Query Strategy](#media-query-strategy)
- [Class-Based Strategy](#class-based-strategy)
- [Attribute-Based Strategy](#attribute-based-strategy)
- [Theme Switching Implementation](#theme-switching-implementation)
- [Respecting User Preferences](#respecting-user-preferences)
---
## Media Query Strategy
Use the system preference for dark mode detection.
### Configuration
**Default behavior** (v4):
```css
/* No configuration needed - dark: variant works by default */
@import 'tailwindcss';
```
**Generated CSS**:
```css
@media (prefers-color-scheme: dark) {
.dark\:bg-slate-900 {
background-color: oklch(20.8% 0.042 265.755);
}
}
```
### Usage
```tsx
export function Card({ children }: { children: React.ReactNode }) {
return (
<div className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-50">
{children}
</div>
);
}
```
### Pros & Cons
**Pros**:
- Respects system preference automatically
- No JavaScript needed
- Simple implementation
- No FOUC (flash of unstyled content)
**Cons**:
- Users can't override system preference
- No manual toggle control
- Changes when system setting changes
### When to Use
- Documentation sites
- Content-focused websites
- Apps where system preference is preferred
- No need for manual theme switching
## Class-Based Strategy
Toggle dark mode with a `.dark` class on the root element.
### Configuration
**Pure v4 approach**: Use a v3 config file with darkMode setting:
```js
// tailwind.config.js (for v3 compatibility)
module.exports = {
darkMode: 'class', // or 'selector' (same as 'class')
};
```
**Note**: In pure v4, the default `dark:` variant uses media queries (`prefers-color-scheme: dark`). To use class-based dark mode, you need to either:
1. Use a v3 config file with `darkMode: 'class'` (shown above)
2. Use `@import "tailwindcss/compat"` and provide a config
3. Define a custom variant with `@custom-variant`
### Generated CSS
```css
.dark .dark\:bg-slate-900 {
background-color: oklch(20.8% 0.042 265.755);
}
```
### Usage
```tsx
export function App() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [isDark]);
return (
<div className="bg-white dark:bg-slate-900">
<button onClick={() => setIsDark(!isDark)}>
Toggle Theme
</button>
</div>
);
}
```
### Pros & Cons
**Pros**:
- Full JavaScript control
- User can override system preference
- Easy to implement manual toggle
- Widely supported pattern
**Cons**:
- Requires JavaScript
- Potential FOUC without SSR handling
- Class management overhead
### When to Use
- Applications with theme toggle
- User preference override needed
- Dashboard/admin interfaces
- Apps with per-user theme settings
## Attribute-Based Strategy
Use a `data-theme` attribute for more semantic theming.
### Configuration (v3 compat)
```js
// tailwind.config.js (v3 compat mode)
module.exports = {
darkMode: ['class', '[data-theme="dark"]'],
};
```
### Generated CSS
```css
[data-theme="dark"] .dark\:bg-slate-900 {
background-color: oklch(20.8% 0.042 265.755);
}
```
### Usage
```tsx
export function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return (
<div className="bg-white dark:bg-slate-900">
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Toggle Theme
</button>
</div>
);
}
```
### Multiple Themes
Extend beyond light/dark with multiple theme attributes:
```tsx
type Theme = 'light' | 'dark' | 'aviation' | 'high-contrast';
export function App() {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return (
<div className="bg-white dark:bg-slate-900 [&[data-theme='aviation']]:bg-blue-950">
<select value={theme} onChange={(e) => setTheme(e.target.value as Theme)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="aviation">Aviation</option>
<option value="high-contrast">High Contrast</option>
</select>
</div>
);
}
```
### Pros & Cons
**Pros**:
- Semantic HTML attribute
- Supports multiple themes (not just light/dark)
- Easy to inspect in DevTools
- Clear intent
**Cons**:
- Requires JavaScript
- More verbose selector in CSS
- Less common pattern
### When to Use
- Multi-theme applications
- Semantic HTML preferences
- Complex theming systems
- Better DevTools debugging
## Theme Switching Implementation
Complete implementation with persistence and SSR support.
### React Hook
```tsx
// hooks/use-theme.ts
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
});
useEffect(() => {
const root = document.documentElement;
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
const effectiveTheme = theme === 'system' ? systemTheme : theme;
root.classList.remove('light', 'dark');
root.classList.add(effectiveTheme);
localStorage.setItem('theme', theme);
}, [theme]);
// Listen for system theme changes
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(systemTheme);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
return { theme, setTheme };
}
```
### Theme Provider Component
```tsx
// components/theme-provider.tsx
import { createContext, useContext, type ReactNode } from 'react';
import { useTheme } from '@/hooks/use-theme';
type ThemeContextValue = ReturnType<typeof useTheme>;
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const value = useTheme();
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useThemeContext() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useThemeContext must be used within ThemeProvider');
}
return context;
}
```
### Theme Toggle Component
```tsx
// components/theme-toggle.tsx
import { Moon, Sun, Monitor } from 'lucide-react';
import { useThemeContext } from '@/components/theme-provider';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
const { theme, setTheme } = useThemeContext();
const cycleTheme = () => {
const themes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
const currentIndex = themes.indexOf(theme);
const nextIndex = (currentIndex + 1) % themes.length;
setTheme(themes[nextIndex]);
};
const Icon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
return (
<Button
variant="outline"
size="icon"
onClick={cycleTheme}
aria-label={`Current theme: theme. Click to cycle themes.`}
>
<Icon className="h-4 w-4" />
</Button>
);
}
```
### SSR Script (Prevent FOUC)
Inject this script before any styled content to prevent flash:
```tsx
// app/layout.tsx (Next.js example)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme') || 'system';
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const effectiveTheme = theme === 'system' ? systemTheme : theme;
document.documentElement.classList.add(effectiveTheme);
})();
`,
}}
/>
</head>
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
```
## Respecting User Preferences
### Reduced Motion
Always respect `prefers-reduced-motion`:
```css
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
**Usage in components**:
```tsx
export function Card() {
return (
<div className="transition-all duration-300 motion-reduce:transition-none">
Content
</div>
);
}
```
### High Contrast
Support high contrast mode:
```css
@media (prefers-contrast: high) {
.button {
border-width: 2px;
}
}
```
**Tailwind utilities**:
```html
<button class="border contrast-more:border-2">
High Contrast Button
</button>
```
### Forced Colors
Respect forced colors mode (Windows High Contrast):
```tsx
export function Card() {
return (
<div className="bg-white dark:bg-slate-900 forced-colors:bg-[Canvas] forced-colors:border forced-colors:border-[CanvasText]">
Content
</div>
);
}
```
### Combined Example
```tsx
export function AccessibleCard({ children }: { children: React.ReactNode }) {
return (
<div
className={`
bg-white dark:bg-slate-900
text-slate-900 dark:text-slate-50
rounded-lg
transition-colors duration-200
motion-reduce:transition-none
border border-transparent
contrast-more:border-slate-300
forced-colors:bg-[Canvas]
forced-colors:border-[CanvasText]
`}
>
{children}
</div>
);
}
```
FILE:references/setup.md
# Setup & Installation
## Contents
- [Package Installation](#package-installation)
- [Vite Plugin Configuration](#vite-plugin-configuration)
- [TypeScript Configuration](#typescript-configuration)
- [CSS Entry Point](#css-entry-point)
- [Why No Config Files](#why-no-config-files)
---
## Package Installation
Install Tailwind CSS v4 with the Vite plugin:
```bash
pnpm add -D tailwindcss@next @tailwindcss/vite@next
```
**Complete package.json example**:
```json
{
"name": "amelia-dashboard",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.0.0",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}
```
## Vite Plugin Configuration
Use the `@tailwindcss/vite` plugin (NOT the PostCSS plugin):
```ts
// vite.config.ts
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
});
```
**Plugin options**:
```ts
export type PluginOptions = {
/**
* Optimize and minify the output CSS.
* Default: true in build mode, false in dev mode
*/
optimize?: boolean | { minify?: boolean };
};
// Example with options
tailwindcss({
optimize: {
minify: true,
},
});
```
**How it works**:
- Scans source files for Tailwind class candidates
- Intercepts CSS files containing `@import 'tailwindcss'`
- Generates utilities based on detected classes
- Watches for file changes in dev mode
- Optimizes and minifies in build mode
## TypeScript Configuration
Add `@types/node` for path resolution in Vite config:
```json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path resolution */
"types": ["node"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
```
**Why `@types/node` is needed**:
- Vite uses Node.js path resolution APIs
- Required for `import.meta.env` types
- Enables `path.resolve()` in config files
## CSS Entry Point
Create a single CSS file that imports Tailwind:
```css
/* src/index.css */
@import 'tailwindcss';
```
**That's it.** No other imports or configuration needed.
**Import in your app**:
```tsx
// src/main.tsx
import './index.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
```
**Advanced: Multiple entry points**:
```css
/* src/index.css */
@import 'tailwindcss';
/* Custom theme for this entry point */
@theme {
--color-primary: oklch(60% 0.24 262);
}
/* Custom utilities */
@layer utilities {
.content-auto {
content-visibility: auto;
}
}
```
## Why No Config Files
Tailwind v4 eliminates separate configuration files in favor of CSS-first configuration.
### No `tailwind.config.js`
**v3 approach** (separate JS config):
```js
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: '#3b82f6',
},
},
},
};
```
**v4 approach** (CSS-first):
```css
@theme {
--color-primary: oklch(60% 0.24 262);
}
```
**Benefits**:
- Configuration lives with styles
- No build-time JS evaluation
- Better CSS tooling support (syntax highlighting, autocomplete)
- Easier to understand what CSS gets generated
- No context switching between files
### No `postcss.config.js`
**v3 approach** (PostCSS plugin):
```js
// postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
**v4 approach** (Vite plugin):
```ts
// vite.config.ts
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss()],
});
```
**Benefits**:
- Faster builds (no PostCSS overhead)
- Integrated with Vite's dev server
- Better HMR (Hot Module Replacement)
- Automatic source map generation
- Native ES modules support
### Content Detection
**v3 approach** (manual content paths):
```js
// tailwind.config.js
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
};
```
**v4 approach** (automatic scanning):
```css
/* Auto-scans all files by default */
@import 'tailwindcss';
/* Optional: Custom source patterns */
@source "src/**/*.{js,ts,jsx,tsx}";
@source "components/**/*.vue";
```
**Benefits**:
- Zero configuration by default
- Explicit control when needed
- CSS-based configuration
- Easier to understand and debug
FILE:references/theming.md
# Theming & Design Tokens
## Contents
- [@theme Directive Modes](#theme-directive-modes)
- [CSS Variable Naming Conventions](#css-variable-naming-conventions)
- [OKLCH Color System](#oklch-color-system)
- [Aviation Theme Example](#aviation-theme-example)
- [Two-Tier Variable System](#two-tier-variable-system)
- [Custom Font Configuration](#custom-font-configuration)
- [Animation Keyframes](#animation-keyframes)
---
## @theme Directive Modes
Tailwind v4 provides multiple modes for defining theme values. Modes can be combined (e.g., `@theme default inline`, `@theme inline reference`).
### @theme (default mode)
Generates CSS variables that can be referenced in custom CSS:
```css
@theme {
--color-brand: oklch(60% 0.24 262);
--spacing: 0.25rem;
}
```
**Generated CSS**:
```css
:root {
--color-brand: oklch(60% 0.24 262);
--spacing: 0.25rem;
}
```
**Usage in utilities**:
```html
<div class="text-brand">Uses var(--color-brand)</div>
```
**Usage in custom CSS**:
```css
.custom-element {
color: var(--color-brand);
padding: calc(var(--spacing) * 4);
}
```
### @theme inline
Inlines values directly without CSS variable indirection:
```css
@theme inline {
--color-brand: oklch(60% 0.24 262);
}
```
**Generated CSS** (when `text-brand` is used):
```css
.text-brand {
color: oklch(60% 0.24 262);
}
```
**When to use**:
- Better performance (no `var()` lookups)
- Static values that won't change
- Utilities with multiple values (animations, shadows)
- Production builds with no runtime theming
### @theme reference
Inlines values as fallbacks without emitting CSS variables to :root:
```css
@theme reference {
--color-internal: oklch(50% 0.1 180);
}
```
**Generated CSS** (when `bg-internal` is used):
```css
.bg-internal {
background-color: var(--color-internal, oklch(50% 0.1 180));
}
```
**Key behavior**: No `:root` variable is created, but the utility still works by using the value as a fallback in `var()`.
**When to use**:
- Provide fallback values without CSS variable overhead
- Reduce :root bloat while maintaining utility functionality
- Values that should work even if the variable isn't defined elsewhere
- Combine with `inline` for direct value substitution (e.g., `@theme reference inline`)
### @theme default
Explicitly marks theme values as defaults that can be overridden:
```css
@theme default {
--color-primary: oklch(60% 0.24 262);
}
/* Later in the file or another file */
@theme {
--color-primary: oklch(70% 0.20 180); /* This overrides the default */
}
```
**Generated CSS**:
```css
:root, :host {
--color-primary: oklch(70% 0.20 180);
}
```
**When to use**:
- Providing base theme values that can be customized
- Library or framework default themes
- Creating overridable design systems
- Used extensively in Tailwind's built-in `theme.css`
**Mode combinations**:
- `@theme default inline` - Default values, inlined directly
- `@theme default reference` - Default fallbacks without :root emission
- `@theme default inline reference` - All three combined
## CSS Variable Naming Conventions
Tailwind v4 uses consistent naming patterns for theme variables:
### Colors
```css
--color-{name}-{shade}
```
**Examples**:
```css
@theme {
--color-primary-500: oklch(60% 0.24 262);
--color-surface-900: oklch(21% 0.006 286);
--color-success-600: oklch(62.7% 0.194 149);
}
/* Usage: text-primary-500, bg-surface-900, border-success-600 */
```
### Spacing
```css
--spacing: {base-unit}
```
**Example**:
```css
@theme {
--spacing: 0.25rem; /* Base unit (4px at 16px root) */
}
/* Generated scale:
p-1 → padding: calc(0.25rem * 1) → 4px
p-4 → padding: calc(0.25rem * 4) → 16px
p-12 → padding: calc(0.25rem * 12) → 48px
*/
```
### Fonts
```css
--font-{family}
--font-{family}--{feature}
```
**Examples**:
```css
@theme {
--font-sans: ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--font-display: 'Inter Variable', system-ui;
--font-display--font-variation-settings: 'wght' 400;
--font-display--font-feature-settings: 'cv02', 'cv03';
}
/* Usage: font-sans, font-mono, font-display */
```
### Breakpoints
```css
--breakpoint-{size}: {value}
```
**Examples**:
```css
@theme {
--breakpoint-sm: 40rem; /* 640px */
--breakpoint-md: 48rem; /* 768px */
--breakpoint-lg: 64rem; /* 1024px */
--breakpoint-xl: 80rem; /* 1280px */
--breakpoint-2xl: 96rem; /* 1536px */
}
```
### Animations
```css
--animate-{name}: {animation-value}
```
**Examples**:
```css
@theme inline {
--animate-spin: spin 1s linear infinite;
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-beacon: beacon 2s ease-in-out infinite;
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
50% { opacity: 0.5; }
}
@keyframes beacon {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.05); }
}
}
/* Usage: animate-spin, animate-pulse, animate-beacon */
```
## OKLCH Color System
OKLCH (Oklab LCH) provides perceptually uniform colors with consistent lightness across all hues.
### Syntax
```css
oklch(L% C H / A)
```
- **L (Lightness)**: 0% (black) to 100% (white)
- **C (Chroma)**: 0 (gray) to ~0.4 (vibrant)
- **H (Hue)**: 0-360 degrees
- **A (Alpha)**: Optional, 0-1
### Hue Wheel
```
0° / 360° - Red
30° - Orange
60° - Yellow
120° - Green
180° - Cyan
240° - Blue
270° - Indigo
300° - Magenta
```
### Complete Color Palette
```css
@theme {
/* Blue scale (H ≈ 260) */
--color-blue-50: oklch(97% 0.014 254.604);
--color-blue-100: oklch(93.2% 0.032 255.585);
--color-blue-200: oklch(88.2% 0.059 254.128);
--color-blue-300: oklch(80.9% 0.105 251.813);
--color-blue-400: oklch(70.7% 0.165 254.624);
--color-blue-500: oklch(62.3% 0.214 259.815);
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-700: oklch(48.8% 0.243 264.376);
--color-blue-800: oklch(42.4% 0.199 265.638);
--color-blue-900: oklch(37.9% 0.146 265.522);
--color-blue-950: oklch(28.2% 0.091 267.935);
/* Slate scale (neutral with slight blue tint) */
--color-slate-50: oklch(98.4% 0.003 247.858);
--color-slate-100: oklch(96.8% 0.007 247.896);
--color-slate-200: oklch(92.9% 0.013 255.508);
--color-slate-300: oklch(86.9% 0.022 252.894);
--color-slate-400: oklch(70.4% 0.04 256.788);
--color-slate-500: oklch(55.4% 0.046 257.417);
--color-slate-600: oklch(44.6% 0.043 257.281);
--color-slate-700: oklch(37.2% 0.044 257.287);
--color-slate-800: oklch(27.9% 0.041 260.031);
--color-slate-900: oklch(20.8% 0.042 265.755);
--color-slate-950: oklch(12.9% 0.042 264.695);
}
```
### Chroma Guidelines
- **0**: Pure gray (achromatic)
- **0.01-0.05**: Subtle tint (slate, zinc)
- **0.10-0.15**: Muted colors (good for backgrounds)
- **0.15-0.25**: Vibrant colors (good for UI elements)
- **0.25-0.40**: Maximum saturation (use sparingly)
## Aviation Theme Example
Custom color palette for an aviation-themed dashboard:
```css
@theme {
/* Flight status colors */
--color-on-time: oklch(72.3% 0.219 149.579); /* Green-600 */
--color-delayed: oklch(76.9% 0.188 70.08); /* Amber-500 */
--color-cancelled: oklch(63.7% 0.237 25.331); /* Red-500 */
--color-diverted: oklch(68.5% 0.169 237.323); /* Sky-500 */
/* Navigation colors */
--color-runway: oklch(87.1% 0.006 286.286); /* Zinc-300 */
--color-taxiway: oklch(70.5% 0.015 286.067); /* Zinc-400 */
--color-apron: oklch(55.2% 0.016 285.938); /* Zinc-500 */
/* Radar colors */
--color-primary-radar: oklch(74.6% 0.16 232.661); /* Sky-400 */
--color-secondary-radar: oklch(76.5% 0.177 163.223); /* Emerald-400 */
/* Map layers */
--color-airspace-class-a: oklch(70.7% 0.165 254.624); /* Blue-400 */
--color-airspace-class-b: oklch(84.1% 0.238 128.85); /* Lime-400 */
--color-airspace-class-c: oklch(71.8% 0.202 349.761); /* Pink-400 */
}
```
## Two-Tier Variable System
Separate design tokens from semantic naming:
```css
@theme {
/* Tier 1: Design tokens (OKLCH primitives) */
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-slate-50: oklch(98.4% 0.003 247.858);
--color-slate-800: oklch(27.9% 0.041 260.031);
--color-slate-900: oklch(20.8% 0.042 265.755);
--color-emerald-500: oklch(69.6% 0.17 162.48);
/* Tier 2: Semantic mappings */
--color-primary: var(--color-blue-600);
--color-surface: var(--color-slate-900);
--color-surface-raised: var(--color-slate-800);
--color-text: var(--color-slate-50);
--color-success: var(--color-emerald-500);
}
/* Usage in components */
.button-primary {
background-color: var(--color-primary);
color: var(--color-text);
}
.card {
background-color: var(--color-surface-raised);
}
```
**Benefits**:
- Design tokens maintain consistency
- Semantic names convey intent
- Easy theme switching (just remap tier 2)
- Clear separation of concerns
## Custom Font Configuration
Configure custom fonts with variable font features:
```css
@theme {
/* Font families */
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-display: 'Manrope Variable', system-ui, sans-serif;
/* Variable font settings for --font-display */
--font-display--font-variation-settings: 'wght' 600;
/* OpenType features for --font-display */
--font-display--font-feature-settings: 'ss01', 'ss02', 'cv05';
/* Font weights */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Letter spacing */
--tracking-tight: -0.025em;
--tracking-normal: 0em;
--tracking-wide: 0.025em;
}
```
**Load fonts in HTML**:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:[email protected]&display=swap" rel="stylesheet">
```
**Usage**:
```html
<h1 class="font-display font-semibold tracking-tight">Aviation Dashboard</h1>
<code class="font-mono text-sm">ATC-1234</code>
```
## Animation Keyframes
Define custom animations with `@theme inline` and `@keyframes`:
```css
@theme inline {
/* Simple animations */
--animate-fade-in: fade-in 0.3s ease-out;
--animate-slide-up: slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1);
/* Complex animations */
--animate-beacon: beacon 2s ease-in-out infinite;
--animate-pulse-glow: pulse-glow 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
/* Keyframe definitions */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes beacon {
0%, 100% {
opacity: 1;
transform: scale(1);
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
}
50% {
opacity: 0.9;
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 8px 2px rgba(34, 197, 94, 0.4);
}
50% {
box-shadow: 0 0 16px 4px rgba(34, 197, 94, 0.8);
}
}
}
```
**Usage**:
```html
<div class="animate-fade-in">Fades in on mount</div>
<div class="animate-beacon">Pulsing beacon effect</div>
<div class="animate-pulse-glow">Glowing status indicator</div>
```
**Respecting prefers-reduced-motion**:
```css
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
Reviews shadcn/ui components for CVA patterns, composition with asChild, accessibility states, and data-slot usage. Use when reviewing React components using...
---
name: shadcn-code-review
description: Reviews shadcn/ui components for CVA patterns, composition with asChild, accessibility states, and data-slot usage. Use when reviewing React components using shadcn/ui, Radix primitives, or Tailwind styling.
---
# shadcn/ui Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| className in CVA, missing VariantProps, compound variants | [references/cva-patterns.md](references/cva-patterns.md) |
| asChild without Slot, missing Context, component composition | [references/composition.md](references/composition.md) |
| Missing focus-visible, aria-invalid, disabled states | [references/accessibility.md](references/accessibility.md) |
| Missing data-slot, incorrect CSS targeting | [references/data-slot.md](references/data-slot.md) |
## Review Checklist
- [ ] `cn()` receives className, not CVA variants
- [ ] `VariantProps<typeof variants>` exported for consumers
- [ ] Compound variants used for complex state combinations
- [ ] `asChild` pattern uses `@radix-ui/react-slot`
- [ ] Context used for component composition (Card, Accordion, etc.)
- [ ] `focus-visible:` states, not just `:focus`
- [ ] `aria-invalid`, `aria-disabled` for form states
- [ ] `disabled:` variants for all interactive elements
- [ ] `sr-only` for screen reader text
- [ ] `data-slot` attributes for targetable composition parts
- [ ] CSS uses `has()` selectors for state-based styling
- [ ] No direct className overrides of variant styles
## Hard gates (before writing findings)
Run these in order. **Do not draft user-facing findings until every gate passes** for the batch you are about to report.
1. **Location evidence** — **Pass:** Each issue lists a repo path and either a line range or a short verbatim quote from the file you read (not from memory or diff-only guesswork).
2. **Exemption check** — **Pass:** For each issue, you can state in one line why it is *not* covered by [Valid Patterns (Do NOT Flag)](#valid-patterns-do-not-flag).
3. **Context-sensitive claims** — **Pass:** For accessibility or Radix-related flags, you checked the file for imports/wrappers showing what actually runs (or you cite the concrete gap).
4. **Protocol** — **Pass:** You completed the Pre-Report Verification Checklist in [review-verification-protocol](../review-verification-protocol/SKILL.md) for this review.
## Valid Patterns (Do NOT Flag)
These are correct patterns that should NOT be flagged as issues:
- `max-h-(--var)` - correct Tailwind v4 CSS variable syntax (NOT v3 bracket notation)
- `text-[color:var(--x)]` - valid arbitrary value syntax
- Copying shadcn component code into project - intended usage pattern
- Not documenting copied shadcn components - library internals, not custom code
- Using cn() with many arguments - composition is the pattern
- Conditional classes in cn() arrays - valid Tailwind pattern
- Extending primitive components without additional docs - well-known base
## Context-Sensitive Rules
Apply these rules with appropriate context awareness:
- Flag accessibility issues ONLY IF not handled by Radix primitives underneath
- Flag missing aria labels ONLY IF component isn't using accessible radix primitive
- Flag variant proliferation ONLY IF variants could be composed from existing
- Flag component documentation ONLY IF it's custom code, not copied shadcn
## Library Convention Note
shadcn/ui components are designed to be copied and modified. Code review should focus on:
- Custom modifications made to copied components
- Integration with application state/data
- Accessibility in custom usage contexts
Do NOT flag:
- Standard shadcn component internals
- Radix primitive usage patterns
- Default variant implementations
## When to Load References
- Reviewing variant definitions → cva-patterns.md
- Reviewing component composition with asChild → composition.md
- Reviewing form components or interactive elements → accessibility.md
- Reviewing multi-part components (Card, Select, etc.) → data-slot.md
## Review Questions
1. Are CVA variants properly separated from className props?
2. Does asChild composition work correctly with Slot?
3. Are all accessibility states (focus, invalid, disabled) handled?
4. Are data-slot attributes used for component part targeting?
5. Can consumers extend variants without breaking composition?
## Before Submitting Findings
Complete [Hard gates](#hard-gates-before-writing-findings) (especially gate 4), then report only issues that still pass the [review-verification-protocol](../review-verification-protocol/SKILL.md) pre-report checks.
FILE:references/accessibility.md
# Accessibility Patterns
## Critical Anti-Patterns
### 1. Using :focus Instead of :focus-visible
**Problem**: Visible focus rings on mouse clicks create poor UX. Use focus-visible for keyboard-only focus.
```tsx
// BAD - :focus shows ring on click
const buttonVariants = cva(
"rounded focus:ring-2 focus:ring-primary" // Shows ring on mouse click
)
// GOOD - :focus-visible shows ring only for keyboard
const buttonVariants = cva(
"rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
)
// Also apply to inputs:
const inputVariants = cva(
"border rounded px-3 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)
```
### 2. Missing aria-invalid for Form States
**Problem**: Screen readers cannot announce validation errors without aria-invalid.
```tsx
// BAD - visual error state only
export function Input({ error, className, ...props }) {
return (
<input
className={cn(
"border rounded",
error && "border-red-500", // Visual only
className
)}
{...props}
/>
)
}
// GOOD - aria-invalid with proper error announcement
export function Input({ error, className, ...props }) {
const errorId = React.useId()
return (
<div>
<input
className={cn(
"border rounded focus-visible:ring-2",
error && "border-destructive focus-visible:ring-destructive",
className
)}
aria-invalid={error ? "true" : undefined}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p id={errorId} className="text-sm text-destructive mt-1">
{error}
</p>
)}
</div>
)
}
```
### 3. Missing Disabled States
**Problem**: Disabled elements must have both visual and semantic disabled states.
```tsx
// BAD - CSS only, no semantic disabled
export function Button({ disabled, children }) {
return (
<button className={disabled ? "opacity-50 cursor-not-allowed" : ""}>
{children}
</button>
// Missing disabled attribute and aria-disabled
)
}
// GOOD - semantic + visual disabled
const buttonVariants = cva("rounded px-4 py-2", {
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border hover:bg-accent",
},
},
defaultVariants: { variant: "default" },
})
export function Button({ disabled, variant, className, ...props }) {
return (
<button
className={cn(
buttonVariants({ variant }),
disabled && "opacity-50 cursor-not-allowed pointer-events-none",
className
)}
disabled={disabled}
aria-disabled={disabled}
{...props}
/>
)
}
```
### 4. Missing Screen Reader Text
**Problem**: Icon-only buttons or visual indicators need sr-only text for screen readers.
```tsx
// BAD - icon button with no label
export function CloseButton({ onClick }) {
return (
<button onClick={onClick}>
<X className="h-4 w-4" /> {/* No text for screen readers */}
</button>
)
}
// GOOD - sr-only text for screen readers
export function CloseButton({ onClick }) {
return (
<button
onClick={onClick}
aria-label="Close" // For simple cases
>
<X className="h-4 w-4" />
</button>
)
}
// BETTER - visible text with icon
export function CloseButton({ onClick }) {
return (
<button onClick={onClick}>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
)
}
// For status indicators:
export function Badge({ status, children }) {
return (
<div className="flex items-center gap-2">
<div className={cn(
"h-2 w-2 rounded-full",
status === "online" && "bg-green-500",
status === "offline" && "bg-gray-500"
)} />
<span className="sr-only">{status === "online" ? "Online" : "Offline"}</span>
{children}
</div>
)
}
```
### 5. Missing Keyboard Navigation
**Problem**: Interactive custom elements must support keyboard navigation.
```tsx
// BAD - div with onClick, no keyboard support
export function Card({ onClick, children }) {
return (
<div onClick={onClick} className="cursor-pointer">
{children}
</div>
)
}
// GOOD - proper button with keyboard support
export function Card({ onClick, children, ...props }) {
if (onClick) {
return (
<button
onClick={onClick}
className="text-left w-full"
{...props}
>
{children}
</button>
)
}
return <div {...props}>{children}</div>
}
// For custom interactive elements:
export function Tab({ active, onClick, children }) {
return (
<button
role="tab"
aria-selected={active}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onClick(e)
}
}}
tabIndex={active ? 0 : -1}
className={cn(
"px-4 py-2",
active && "border-b-2 border-primary"
)}
>
{children}
</button>
)
}
```
### 6. Color as Only Indicator
**Problem**: Color alone cannot convey state (WCAG 1.4.1).
```tsx
// BAD - color only for required fields
export function Label({ required, children }) {
return (
<label className={required ? "text-red-500" : ""}>
{children}
</label>
)
}
// GOOD - color + text/icon indicator
export function Label({ required, children }) {
return (
<label>
{children}
{required && (
<>
<span className="text-destructive ml-1" aria-hidden="true">*</span>
<span className="sr-only">(required)</span>
</>
)}
</label>
)
}
// For status:
export function Status({ status }) {
const icons = {
success: <Check className="h-4 w-4" />,
error: <X className="h-4 w-4" />,
warning: <AlertTriangle className="h-4 w-4" />,
}
return (
<div className={cn(
"flex items-center gap-2",
status === "success" && "text-green-600",
status === "error" && "text-destructive",
status === "warning" && "text-yellow-600"
)}>
{icons[status]}
<span>{status}</span> {/* Text accompanies color */}
</div>
)
}
```
### 7. Missing Loading States
**Problem**: Async actions must indicate loading state for screen readers.
```tsx
// BAD - visual spinner only
export function Button({ loading, children, ...props }) {
return (
<button {...props}>
{loading ? <Spinner /> : children}
</button>
)
}
// GOOD - aria-busy with announcement
export function Button({ loading, children, ...props }) {
return (
<button
aria-busy={loading}
disabled={loading}
{...props}
>
{loading && <Spinner className="mr-2 h-4 w-4 animate-spin" />}
{children}
{loading && <span className="sr-only">Loading...</span>}
</button>
)
}
```
## Review Questions
1. Are focus-visible styles used instead of focus?
2. Is aria-invalid set for error states with describedby?
3. Do disabled elements have both disabled and aria-disabled?
4. Are icon-only buttons labeled with sr-only text or aria-label?
5. Do custom interactive elements support keyboard navigation?
6. Is state conveyed through more than just color?
7. Are loading states announced with aria-busy?
FILE:references/composition.md
# Component Composition
## Critical Anti-Patterns
### 1. asChild Without Slot
**Problem**: The asChild pattern requires @radix-ui/react-slot to work correctly.
```tsx
// BAD - asChild without Slot
export function Button({ asChild, children, ...props }) {
if (asChild) {
return children // WRONG - doesn't merge props
}
return <button {...props}>{children}</button>
}
// Usage breaks:
<Button asChild>
<Link href="/">Home</Link> {/* Link doesn't receive Button's props */}
</Button>
// GOOD - using Slot
import { Slot } from "@radix-ui/react-slot"
export function Button({ asChild, className, variant, size, ...props }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
}
// Usage works correctly:
<Button asChild variant="outline">
<Link href="/">Home</Link> {/* Link receives variant styles and all props */}
</Button>
```
### 2. Missing Context for Compound Components
**Problem**: Component parts cannot communicate state without Context.
```tsx
// BAD - no context, state passed via props (brittle)
export function Card({ variant, children }) {
return (
<div className={cardVariants({ variant })}>
{React.Children.map(children, child =>
React.cloneElement(child, { variant }) // WRONG - fragile, breaks with fragments
)}
</div>
)
}
export function CardHeader({ variant, children }) {
return <div className={headerVariants({ variant })}>{children}</div>
}
// GOOD - using Context
const CardContext = React.createContext<{ variant?: string }>({})
export function Card({ variant = "default", children, ...props }) {
return (
<CardContext.Provider value={{ variant }}>
<div className={cn(cardVariants({ variant }))} {...props}>
{children}
</div>
</CardContext.Provider>
)
}
export function CardHeader({ className, ...props }) {
const { variant } = React.useContext(CardContext)
return (
<div
className={cn(headerVariants({ variant }), className)}
{...props}
/>
)
}
// Usage is clean:
<Card variant="elevated">
<CardHeader>Title</CardHeader> {/* Automatically gets variant */}
<CardContent>Content</CardContent>
</Card>
```
### 3. Slot Props Not Merged Correctly
**Problem**: When using asChild, child props must be merged with component props.
```tsx
// BAD - props collision
export function Button({ asChild, onClick, ...props }) {
const Comp = asChild ? Slot : "button"
return <Comp onClick={onClick} {...props} /> // Child's onClick is overwritten
}
// GOOD - proper prop merging with composeEventHandlers
import { composeEventHandlers } from "@radix-ui/primitive"
import { Slot } from "@radix-ui/react-slot"
export function Button({ asChild, onClick, ...props }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
{...props}
onClick={composeEventHandlers(onClick, (e) => {
// Component's onClick logic
})}
/>
)
}
// Or use Radix's component approach:
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild = false, onClick, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
onClick={onClick}
{...props}
/>
)
}
)
```
### 4. Not Forwarding Refs with asChild
**Problem**: Refs break when using asChild without forwardRef.
```tsx
// BAD - ref not forwarded
export function Button({ asChild, ...props }) {
const Comp = asChild ? Slot : "button"
return <Comp {...props} /> // ref won't work
}
// Usage breaks:
const ref = useRef()
<Button ref={ref} asChild>
<Link>Home</Link> {/* ref is lost */}
</Button>
// GOOD - forwardRef with asChild
export const Button = React.forwardRef<
HTMLButtonElement,
ButtonProps
>(({ asChild = false, className, variant, size, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
{...props}
/>
)
})
Button.displayName = "Button"
```
### 5. Polymorphic Components Without Type Safety
**Problem**: Using 'as' prop without proper TypeScript typing loses type safety.
```tsx
// BAD - no type safety
export function Text({ as = "p", ...props }) {
const Comp = as
return <Comp {...props} /> // No type checking for Comp-specific props
}
// GOOD - typed polymorphic component
import { ElementType, ComponentPropsWithoutRef } from "react"
type PolymorphicProps<E extends ElementType> = {
as?: E
} & ComponentPropsWithoutRef<E>
export function Text<E extends ElementType = "p">({
as,
className,
...props
}: PolymorphicProps<E>) {
const Comp = as || "p"
return (
<Comp
className={cn("text-base", className)}
{...props}
/>
)
}
// Usage is type-safe:
<Text as="h1" onClick={(e) => {/* e is typed correctly */}}>Title</Text>
<Text as="a" href="/about">Link</Text> {/* href required for 'a' */}
```
### 6. Overusing React.cloneElement
**Problem**: cloneElement is fragile and breaks with fragments, context, or complex children.
```tsx
// BAD - cloneElement everywhere
export function List({ spacing, children }) {
return (
<ul>
{React.Children.map(children, child =>
React.cloneElement(child, { spacing }) // Breaks with fragments, context
)}
</ul>
)
}
// GOOD - use Context
const ListContext = React.createContext({ spacing: "md" })
export function List({ spacing = "md", children, ...props }) {
return (
<ListContext.Provider value={{ spacing }}>
<ul {...props}>{children}</ul>
</ListContext.Provider>
)
}
export function ListItem({ className, ...props }) {
const { spacing } = React.useContext(ListContext)
return (
<li
className={cn(listItemVariants({ spacing }), className)}
{...props}
/>
)
}
```
## Review Questions
1. Does asChild use Slot from @radix-ui/react-slot?
2. Are compound components using Context for state sharing?
3. Are refs forwarded with React.forwardRef?
4. Are event handlers composed correctly with asChild?
5. Is React.cloneElement avoided in favor of Context?
FILE:references/cva-patterns.md
# CVA Patterns
## Critical Anti-Patterns
### 1. className Passed to CVA Instead of cn()
**Problem**: CVA variants cannot be overridden by consumers. The className should be passed to cn() after CVA, not as a CVA variant.
```tsx
// BAD - className in CVA
import { cva } from "class-variance-authority"
const buttonVariants = cva("base-styles", {
variants: {
variant: { default: "bg-primary", destructive: "bg-destructive" },
size: { sm: "h-9", lg: "h-11" },
className: {}, // WRONG - className is not a variant
},
})
export function Button({ variant, size, className }) {
return <button className={buttonVariants({ variant, size })} />
}
// GOOD - className in cn()
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva("base-styles", {
variants: {
variant: { default: "bg-primary", destructive: "bg-destructive" },
size: { sm: "h-9", lg: "h-11" },
},
defaultVariants: {
variant: "default",
size: "default",
},
})
export interface ButtonProps extends VariantProps<typeof buttonVariants> {
className?: string
}
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
}
```
### 2. Missing VariantProps Export
**Problem**: Consumers cannot type-check variant props correctly.
```tsx
// BAD - no type export
const buttonVariants = cva(...)
export function Button({ variant, size }: { variant?: string, size?: string }) {
return <button className={buttonVariants({ variant, size })} />
}
// GOOD - export VariantProps
import { type VariantProps } from "class-variance-authority"
const buttonVariants = cva(...)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export function Button({ variant, size, className, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
```
### 3. Not Using Compound Variants
**Problem**: Complex state combinations create verbose, repetitive variant definitions.
```tsx
// BAD - manual combinations
const buttonVariants = cva("rounded font-medium", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border border-input bg-background",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
sm: "h-9 px-3 text-xs",
default: "h-10 px-4 py-2",
lg: "h-11 px-8",
},
// Trying to handle all combinations manually - WRONG
variantSize: {
"outline-sm": "border-2", // Don't do this
"ghost-lg": "hover:bg-accent/50",
}
},
})
// GOOD - use compoundVariants
const buttonVariants = cva("rounded font-medium", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border border-input bg-background",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
sm: "h-9 px-3 text-xs",
default: "h-10 px-4 py-2",
lg: "h-11 px-8",
},
},
compoundVariants: [
{
variant: "outline",
size: "sm",
class: "border-2",
},
{
variant: "ghost",
size: "lg",
class: "hover:bg-accent/50",
},
],
defaultVariants: {
variant: "default",
size: "default",
},
})
```
### 4. Hardcoding State Classes Instead of Variants
**Problem**: State-dependent styling should be variants for consistency and reusability.
```tsx
// BAD - hardcoded state classes
export function Input({ disabled, invalid, className }) {
return (
<input
className={cn(
"rounded border px-3 py-2",
disabled && "opacity-50 cursor-not-allowed",
invalid && "border-red-500",
className
)}
disabled={disabled}
/>
)
}
// GOOD - state variants
const inputVariants = cva("rounded border px-3 py-2", {
variants: {
state: {
default: "",
invalid: "border-destructive focus-visible:ring-destructive",
disabled: "opacity-50 cursor-not-allowed",
},
},
defaultVariants: {
state: "default",
},
})
export function Input({ disabled, invalid, className, ...props }) {
const state = disabled ? "disabled" : invalid ? "invalid" : "default"
return (
<input
className={cn(inputVariants({ state }), className)}
disabled={disabled}
aria-invalid={invalid}
{...props}
/>
)
}
```
### 5. Missing defaultVariants
**Problem**: Component behavior is unpredictable without defaults.
```tsx
// BAD - no defaults
const buttonVariants = cva("base", {
variants: {
variant: { default: "bg-primary", outline: "border" },
size: { sm: "h-9", lg: "h-11" },
},
// Missing defaultVariants - what happens with <Button />?
})
// GOOD - explicit defaults
const buttonVariants = cva("base", {
variants: {
variant: { default: "bg-primary", outline: "border" },
size: { sm: "h-9", lg: "h-11" },
},
defaultVariants: {
variant: "default",
size: "sm",
},
})
```
## Review Questions
1. Is className passed to cn() after CVA variants?
2. Are VariantProps exported for type safety?
3. Are compound variants used for complex state combinations?
4. Are state-dependent styles defined as variants?
5. Are defaultVariants specified for all variant groups?
FILE:references/data-slot.md
# data-slot Pattern
## Critical Anti-Patterns
### 1. Missing data-slot Attributes
**Problem**: Component parts cannot be targeted by consumers for custom styling without data-slot.
```tsx
// BAD - no way to target subcomponents
export function Card({ children, ...props }) {
return (
<div className="border rounded-lg" {...props}>
{children}
</div>
)
}
export function CardHeader({ children, ...props }) {
return (
<div className="p-6" {...props}>
{children}
</div>
)
}
// Consumer cannot style CardHeader inside Card without fragile selectors:
<Card className="[&>div]:bg-red-500"> {/* BRITTLE - breaks if structure changes */}
<CardHeader>Title</CardHeader>
</Card>
// GOOD - data-slot for targetable parts
export function Card({ children, ...props }) {
return (
<div className="border rounded-lg" data-slot="card" {...props}>
{children}
</div>
)
}
export function CardHeader({ children, ...props }) {
return (
<div className="p-6" data-slot="card-header" {...props}>
{children}
</div>
)
}
// Consumer can target with stable selector:
<Card className="[&_[data-slot=card-header]]:bg-red-500">
<CardHeader>Title</CardHeader>
</Card>
```
### 2. Not Using has() Selectors for State-Based Styling
**Problem**: Parent styling based on child state requires data-slot + has().
```tsx
// BAD - manual state prop threading
export function Card({ hasError, children }) {
return (
<div className={cn("border", hasError && "border-red-500")}>
{children}
</div>
)
}
export function CardContent({ error, children }) {
return (
<div>
{error && <p className="text-red-500">{error}</p>}
{children}
</div>
)
}
// Usage is verbose:
const [error, setError] = useState("")
<Card hasError={!!error}>
<CardContent error={error}>...</CardContent>
</Card>
// GOOD - has() selector with data-slot
export function Card({ children, ...props }) {
return (
<div
className="border has-[[data-slot=card-content][data-error]]:border-destructive"
data-slot="card"
{...props}
>
{children}
</div>
)
}
export function CardContent({ error, children, ...props }) {
return (
<div data-slot="card-content" data-error={error ? "" : undefined} {...props}>
{error && (
<p className="text-sm text-destructive" data-slot="card-error">
{error}
</p>
)}
{children}
</div>
)
}
// Usage is clean:
<Card>
<CardContent error={error}>...</CardContent>
</Card>
```
### 3. Incorrect CSS Targeting Without data-slot
**Problem**: Targeting by element type or class is fragile and breaks with structural changes.
```tsx
// BAD - targeting by element type
const selectVariants = cva(
// Targeting trigger button directly - fragile
"[&>button]:flex [&>button]:items-center [&>button]:justify-between",
// Targeting value span - fragile
"[&>button>span]:text-sm [&>button>span]:text-muted-foreground"
)
export function Select({ children }) {
return <div className={selectVariants()}>{children}</div>
}
// GOOD - targeting by data-slot
const selectVariants = cva(
"[&_[data-slot=select-trigger]]:flex [&_[data-slot=select-trigger]]:items-center",
"[&_[data-slot=select-value]]:text-sm [&_[data-slot=select-value]]:text-muted-foreground"
)
export function Select({ children, ...props }) {
return (
<div className={selectVariants()} data-slot="select" {...props}>
{children}
</div>
)
}
export function SelectTrigger({ children, ...props }) {
return (
<button data-slot="select-trigger" {...props}>
{children}
</button>
)
}
export function SelectValue({ children, ...props }) {
return (
<span data-slot="select-value" {...props}>
{children}
</span>
)
}
```
### 4. data-state Without data-slot
**Problem**: data-state is useful but needs data-slot for scoped targeting.
```tsx
// BAD - data-state only, no scoping
export function Accordion({ open, children }) {
return (
<div data-state={open ? "open" : "closed"}>
{children}
</div>
)
}
export function AccordionTrigger({ children }) {
return <button>{children}</button>
}
// Consumer cannot target trigger based on parent state:
// Can't write: [&[data-state=open]_button]:rotate-180
// GOOD - data-slot + data-state
export function Accordion({ open, children, ...props }) {
return (
<div
data-slot="accordion"
data-state={open ? "open" : "closed"}
{...props}
>
{children}
</div>
)
}
export function AccordionTrigger({ children, ...props }) {
return (
<button data-slot="accordion-trigger" {...props}>
{children}
</button>
)
}
// Consumer can target:
<Accordion className="[&[data-state=open]_[data-slot=accordion-trigger]]:rotate-180">
<AccordionTrigger>...</AccordionTrigger>
</Accordion>
// Or use has():
<Accordion className="has-[[data-slot=accordion-trigger][aria-expanded=true]]:bg-accent">
```
### 5. Nested Component Targeting
**Problem**: Deeply nested components need data-slot for stable targeting.
```tsx
// BAD - descendant selectors by element
export function Table({ children }) {
return (
<table className="[&_thead_tr]:border-b [&_tbody_tr]:border-b [&_td]:p-4">
{children}
</table>
)
}
// Breaks if you add divs or other elements in structure
// GOOD - data-slot for all parts
export function Table({ children, ...props }) {
return (
<table
data-slot="table"
className="[&_[data-slot=table-header-row]]:border-b [&_[data-slot=table-row]]:border-b [&_[data-slot=table-cell]]:p-4"
{...props}
>
{children}
</table>
)
}
export function TableHeader({ children, ...props }) {
return (
<thead data-slot="table-header" {...props}>
{children}
</thead>
)
}
export function TableRow({ children, ...props }) {
return (
<tr data-slot="table-row" {...props}>
{children}
</tr>
)
}
export function TableCell({ children, ...props }) {
return (
<td data-slot="table-cell" {...props}>
{children}
</td>
)
}
```
### 6. Using data-slot for State Instead of data-state
**Problem**: data-slot is for targeting parts, data-state is for state values.
```tsx
// BAD - using data-slot for state
export function Tab({ active, children }) {
return (
<button
data-slot={active ? "tab-active" : "tab-inactive"} // WRONG - use data-state
>
{children}
</button>
)
}
// GOOD - data-slot for type, data-state for state
export function Tab({ active, children, ...props }) {
return (
<button
data-slot="tab"
data-state={active ? "active" : "inactive"}
role="tab"
aria-selected={active}
{...props}
>
{children}
</button>
)
}
// Targeting:
<TabList className="[&_[data-slot=tab][data-state=active]]:border-b-2">
<Tab active>...</Tab>
</TabList>
```
## Review Questions
1. Do all component parts have data-slot attributes?
2. Are has() selectors used for state-based parent styling?
3. Is CSS targeting using data-slot instead of element types?
4. Are data-state and data-slot used together for stateful components?
5. Can consumers reliably target nested component parts?
6. Is data-slot used for identification and data-state for values?
React Router v7 best practices for data-driven routing. Use when implementing routes, loaders, actions, Form components, fetchers, navigation guards, protect...
---
name: react-router-v7
description: React Router v7 best practices for data-driven routing. Use when implementing routes, loaders, actions, Form components, fetchers, navigation guards, protected routes, or URL search params. Triggers on createBrowserRouter, RouterProvider, useLoaderData, useActionData, useFetcher, NavLink, Outlet.
---
# React Router v7 Best Practices
## Quick Reference
**Router Setup (Data Mode)**:
```tsx
import { createBrowserRouter, RouterProvider } from "react-router";
const router = createBrowserRouter([
{
path: "/",
Component: Root,
ErrorBoundary: RootErrorBoundary,
loader: rootLoader,
children: [
{ index: true, Component: Home },
{ path: "products/:productId", Component: Product, loader: productLoader },
],
},
]);
ReactDOM.createRoot(root).render(<RouterProvider router={router} />);
```
**Framework Mode (Vite plugin)**:
```ts
// routes.ts
import { index, route } from "@react-router/dev/routes";
export default [
index("./home.tsx"),
route("products/:pid", "./product.tsx"),
];
```
## Route Configuration
### Nested Routes with Outlets
```tsx
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
{ index: true, Component: DashboardHome },
{ path: "settings", Component: Settings },
],
},
]);
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Outlet /> {/* Renders child routes */}
</div>
);
}
```
### Dynamic Segments and Splats
```tsx
{ path: "teams/:teamId" } // params.teamId
{ path: ":lang?/categories" } // Optional segment
{ path: "files/*" } // Splat: params["*"]
```
## Key Decision Points
### Form vs Fetcher
**Use `<Form>`**: Creating/deleting with URL change, adding to history
**Use `useFetcher`**: Inline updates, list operations, popovers - no URL change
### Loader vs useEffect
**Use loader**: Data before render, server-side fetch, automatic revalidation
**Use useEffect**: Client-only data, user-interaction dependent, subscriptions
## Gates (decision sequencing)
Answer **in order**. **Pass** means the condition is true; pick the API on the same line and **stop**.
### `<Form>` vs `useFetcher`
1. **Must the URL or history stack change** (bookmark/share, back returns to prior screen)?
- **Pass →** `<Form>` / route `action` (or `useSubmit` + navigation). **Stop.**
- **Fail →** Step 2.
2. **Mutation stays on the same route** (inline edit, modal, list row, no address change)?
- **Pass →** `useFetcher()`. **Stop.**
- **Fail →** Re-check step 1; you may need a dedicated action route or POST to the current URL.
### `loader` vs `useEffect`
1. **Is data needed for correct first render** (or your intended `<Suspense>` boundary) for this route?
- **Pass →** `loader` (Framework: `clientLoader` when appropriate). **Stop.**
- **Fail →** Step 2.
2. **Fetch only after mount** from user action, timer, or subscription (not route entry)?
- **Pass →** `useEffect` / event handlers. **Stop.**
- **Fail →** Prefer loader + revalidation over an effect that mirrors navigation.
## Additional Documentation
- **Data Loading**: See [references/loaders.md](references/loaders.md) for loader patterns, parallel loading, search params
- **Mutations**: See [references/actions.md](references/actions.md) for actions, Form, fetchers, validation
- **Navigation**: See [references/navigation.md](references/navigation.md) for Link, NavLink, programmatic nav
- **Advanced**: See [references/advanced.md](references/advanced.md) for error boundaries, protected routes, lazy loading
## Mode Comparison
| Feature | Framework Mode | Data Mode | Declarative Mode |
|---------|---------------|-----------|------------------|
| Setup | Vite plugin | `createBrowserRouter` | `<BrowserRouter>` |
| Type Safety | Auto-generated types | Manual | Manual |
| SSR Support | Built-in | Manual | Limited |
| Use Case | Full-stack apps | SPAs with control | Simple/legacy |
FILE:ACTIONS.md
# Actions and Mutations
## Basic Action Pattern
```tsx
{
path: "/projects/:id",
action: async ({ request, params }) => {
const formData = await request.formData();
const title = formData.get("title");
await updateProject(params.id, { title });
return { success: true };
},
Component: Project,
}
```
## Form Submission
```tsx
function Project() {
const actionData = useActionData();
return (
<Form method="post">
<input type="text" name="title" />
<button type="submit">Save</button>
{actionData?.success && <p>Saved!</p>}
</Form>
);
}
```
## Redirect After Action
```tsx
import { redirect } from "react-router";
export async function action({ request }) {
const formData = await request.formData();
const project = await createProject(formData);
return redirect(`/projects/project.id`);
}
```
## Form Validation
```tsx
import { data } from "react-router";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email"));
const password = String(formData.get("password"));
const errors: Record<string, string> = {};
if (!email.includes("@")) {
errors.email = "Invalid email address";
}
if (password.length < 12) {
errors.password = "Password must be at least 12 characters";
}
if (Object.keys(errors).length > 0) {
return data({ errors }, { status: 400 }); // 400 prevents revalidation
}
return redirect("/dashboard");
}
export default function Signup() {
const fetcher = useFetcher();
const errors = fetcher.data?.errors;
return (
<fetcher.Form method="post">
<input type="email" name="email" />
{errors?.email && <em>{errors.email}</em>}
<input type="password" name="password" />
{errors?.password && <em>{errors.password}</em>}
<button type="submit">Sign Up</button>
</fetcher.Form>
);
}
```
## Fetchers (Non-Navigation Mutations)
Use fetchers when you DON'T want URL changes:
```tsx
import { useFetcher } from "react-router";
function TodoItem({ todo }) {
const fetcher = useFetcher();
const isDeleting = fetcher.state !== "idle";
return (
<li>
<span>{todo.title}</span>
<fetcher.Form method="post" action="/todos/delete">
<input type="hidden" name="id" value={todo.id} />
<button type="submit" disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</fetcher.Form>
</li>
);
}
```
## Optimistic UI with Fetchers
```tsx
function Component() {
const data = useLoaderData();
const fetcher = useFetcher();
// Show optimistic state while submitting
const title = fetcher.formData?.get("title") || data.title;
return (
<div>
<h1>{title}</h1>
<fetcher.Form method="post">
<input type="text" name="title" />
{fetcher.state !== "idle" && <p>Saving...</p>}
</fetcher.Form>
</div>
);
}
```
## Fetcher for Data Loading (Combobox)
```tsx
function UserSearchCombobox() {
const fetcher = useFetcher<typeof loader>();
return (
<div>
<fetcher.Form method="get" action="/search-users">
<input
type="text"
name="q"
onChange={(e) => fetcher.submit(e.currentTarget.form)}
/>
</fetcher.Form>
{fetcher.data && (
<ul style={{ opacity: fetcher.state === "idle" ? 1 : 0.25 }}>
{fetcher.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}
```
## Optimistic List Updates
```tsx
function TodoList() {
const { todos } = useLoaderData();
const fetcher = useFetcher();
const displayedTodos = todos.filter(todo => {
const isDeleting = fetcher.formData?.get("id") === todo.id;
return !isDeleting;
});
return (
<ul>
{displayedTodos.map(todo => (
<li key={todo.id}>
{todo.title}
<fetcher.Form method="post" action="/todos/delete">
<input type="hidden" name="id" value={todo.id} />
<button type="submit">Delete</button>
</fetcher.Form>
</li>
))}
</ul>
);
}
```
FILE:ADVANCED.md
# Advanced Patterns
## Error Boundaries
### Root Error Boundary (Required)
```tsx
import { useRouteError, isRouteErrorResponse } from "react-router";
function RootErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</>
);
} else if (error instanceof Error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<pre>{error.stack}</pre>
</div>
);
} else {
return <h1>Unknown Error</h1>;
}
}
createBrowserRouter([
{
path: "/",
ErrorBoundary: RootErrorBoundary,
Component: Root,
},
]);
```
### Throwing Errors in Loaders
```tsx
import { data } from "react-router";
export async function loader({ params }) {
const record = await db.getRecord(params.id);
if (!record) {
throw data("Record Not Found", { status: 404 });
}
return record;
}
```
### Nested Error Boundaries
```tsx
createBrowserRouter([
{
path: "/app",
ErrorBoundary: AppErrorBoundary,
children: [
{
path: "invoices/:id",
ErrorBoundary: InvoiceErrorBoundary,
Component: Invoice,
},
],
},
]);
```
## Protected Routes
### Component-Based Protection
```tsx
import { Navigate, useLocation } from "react-router";
function RequireAuth({ children }: { children: JSX.Element }) {
const auth = useAuth();
const location = useLocation();
if (!auth.user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// Route configuration
{
path: "/protected",
element: (
<RequireAuth>
<ProtectedPage />
</RequireAuth>
),
}
// In login handler - redirect back
function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
function handleLogin() {
auth.signin(() => {
navigate(from, { replace: true });
});
}
}
```
### Middleware (Framework Mode)
```tsx
import { redirect } from "react-router";
async function authMiddleware({ context, request }) {
const userId = getUserId(request);
if (!userId) {
throw redirect("/login");
}
const user = await getUserById(userId);
context.set(userContext, user);
}
createBrowserRouter([
{
path: "/dashboard",
middleware: [authMiddleware],
Component: Dashboard,
},
]);
```
## Lazy Loading / Code Splitting
### Data Mode Lazy Loading
```tsx
createBrowserRouter([
{
path: "/app",
lazy: async () => {
const [Component, loader] = await Promise.all([
import("./app"),
import("./app-loader"),
]);
return { Component, loader };
},
},
]);
```
### Declarative Mode Lazy Loading
```tsx
import React from "react";
const About = React.lazy(() => import("./pages/About"));
<Routes>
<Route
path="about"
element={
<React.Suspense fallback={<>Loading...</>}>
<About />
</React.Suspense>
}
/>
</Routes>
```
## Common Route Patterns
### Optional Segments
```tsx
{ path: ":lang?/categories" } // Optional dynamic segment
{ path: "users/:userId/edit?" } // Optional static segment at end
```
### Catch-All / Splat Routes
```tsx
{ path: "files/*" }
// Access splat in loader
loader: ({ params }) => {
const filePath = params["*"]; // "path/to/file.txt"
}
```
### Multiple Params
```tsx
{ path: "users/:userId/posts/:postId" }
// params.userId, params.postId available in loader/component
```
## Index vs Path Routes
```tsx
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
// Index route - renders when parent path matches exactly
{ index: true, Component: DashboardHome },
// Path route - renders at parent + path
{ path: "settings", Component: Settings },
{ path: "profile", Component: Profile },
],
},
]);
```
**Index renders at**: `/dashboard`
**Settings renders at**: `/dashboard/settings`
FILE:LOADERS.md
# Data Loading Patterns
## Basic Loader
```tsx
{
path: "/teams/:teamId",
loader: async ({ params, request }) => {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const team = await fetchTeam(params.teamId, query);
return { team, name: team.name };
},
Component: Team,
}
function Team() {
const data = useLoaderData();
return <h1>{data.name}</h1>;
}
```
## Parallel Data Loading
Nested routes load data in parallel automatically:
```tsx
createBrowserRouter([
{
path: "/",
loader: rootLoader, // Loads in parallel
children: [
{
path: "project/:id",
loader: projectLoader, // Loads in parallel with rootLoader
},
],
},
]);
```
## Search Params in Loaders
```tsx
{
path: "/search",
loader: async ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const page = url.searchParams.get("page") || "1";
return { results: await search(query, parseInt(page)) };
},
}
function SearchPage() {
const { results } = useLoaderData();
return (
<Form method="get">
<input type="text" name="q" />
<button type="submit">Search</button>
</Form>
);
}
```
## useSearchParams Hook
```tsx
import { useSearchParams } from "react-router";
function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get("q");
return (
<input
value={query || ""}
onChange={(e) => setSearchParams({ q: e.target.value })}
/>
);
}
```
## Revalidation Control
```tsx
function shouldRevalidate({ currentUrl, nextUrl, formAction }) {
return currentUrl.pathname !== nextUrl.pathname;
}
createBrowserRouter([
{
path: "/data",
shouldRevalidate,
loader: dataLoader,
},
]);
```
## Framework Mode Loaders
```tsx
// product.tsx
import { Route } from "./+types/product";
export async function loader({ params }: Route.LoaderArgs) {
const product = await getProduct(params.pid);
return { product };
}
export default function Product({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.product.name}</div>;
}
```
FILE:NAVIGATION.md
# Navigation Patterns
## NavLink (Active Styling)
```tsx
import { NavLink } from "react-router";
<NavLink to="/messages" end>
Messages
</NavLink>
// CSS styling
a.active { color: red; }
a.pending { animation: pulse 1s infinite; }
// Callback styling
<NavLink
to="/messages"
className={({ isActive, isPending }) =>
isActive ? "active" : isPending ? "pending" : ""
}
>
Messages
</NavLink>
```
## Link (No Active Styling)
```tsx
import { Link } from "react-router";
<Link to="/login">Login</Link>
<Link to={{ pathname: "/search", search: "?q=term" }}>Search</Link>
```
## Programmatic Navigation
```tsx
import { useNavigate } from "react-router";
function Component() {
const navigate = useNavigate();
// Use sparingly - only for non-user-initiated navigation
useEffect(() => {
if (inactivityTimeout) {
navigate("/logout");
}
}, [inactivityTimeout]);
// Or with options
navigate("/dashboard", { replace: true });
navigate(-1); // Go back
}
```
## Redirect in Loaders
```tsx
import { redirect } from "react-router";
export async function loader({ request }) {
const user = await getUser(request);
if (!user) {
return redirect("/login");
}
return { user };
}
```
## Pending UI (Navigation State)
```tsx
import { useNavigation } from "react-router";
function Root() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
return (
<div>
{isNavigating && <GlobalSpinner />}
<Outlet />
</div>
);
}
```
## Form Submission State
```tsx
function Component() {
const navigation = useNavigation();
const isSubmitting = navigation.formAction === "/recipes/new";
return (
<Form method="post" action="/recipes/new">
<button type="submit">
{isSubmitting ? "Saving..." : "Create Recipe"}
</button>
</Form>
);
}
```
## Index Routes
```tsx
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
{ index: true, Component: DashboardHome }, // Renders at /dashboard
{ path: "settings", Component: Settings }, // Renders at /dashboard/settings
],
},
]);
```
## Layout Routes (No Path)
```tsx
createBrowserRouter([
{
Component: MarketingLayout, // No path, just layout wrapper
children: [
{ index: true, Component: Home },
{ path: "contact", Component: Contact },
],
},
]);
```
## Navigate Component
```tsx
import { Navigate, useLocation } from "react-router";
function RequireAuth({ children }) {
const auth = useAuth();
const location = useLocation();
if (!auth.user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
```
FILE:references/actions.md
# Actions and Mutations
## Contents
- [Basic Action Pattern](#basic-action-pattern)
- [Form Submission](#form-submission)
- [Redirect After Action](#redirect-after-action)
- [Form Validation](#form-validation)
- [Fetchers (Non-Navigation Mutations)](#fetchers-non-navigation-mutations)
- [Optimistic UI with Fetchers](#optimistic-ui-with-fetchers)
- [Fetcher for Data Loading (Combobox)](#fetcher-for-data-loading-combobox)
- [Optimistic List Updates](#optimistic-list-updates)
---
## Basic Action Pattern
```tsx
{
path: "/projects/:id",
action: async ({ request, params }) => {
const formData = await request.formData();
const title = formData.get("title");
await updateProject(params.id, { title });
return { success: true };
},
Component: Project,
}
```
## Form Submission
```tsx
function Project() {
const actionData = useActionData();
return (
<Form method="post">
<input type="text" name="title" />
<button type="submit">Save</button>
{actionData?.success && <p>Saved!</p>}
</Form>
);
}
```
## Redirect After Action
```tsx
import { redirect } from "react-router";
export async function action({ request }) {
const formData = await request.formData();
const project = await createProject(formData);
return redirect(`/projects/project.id`);
}
```
## Form Validation
```tsx
import { data } from "react-router";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email"));
const password = String(formData.get("password"));
const errors: Record<string, string> = {};
if (!email.includes("@")) {
errors.email = "Invalid email address";
}
if (password.length < 12) {
errors.password = "Password must be at least 12 characters";
}
if (Object.keys(errors).length > 0) {
return data({ errors }, { status: 400 }); // 400 prevents revalidation
}
return redirect("/dashboard");
}
export default function Signup() {
const fetcher = useFetcher();
const errors = fetcher.data?.errors;
return (
<fetcher.Form method="post">
<input type="email" name="email" />
{errors?.email && <em>{errors.email}</em>}
<input type="password" name="password" />
{errors?.password && <em>{errors.password}</em>}
<button type="submit">Sign Up</button>
</fetcher.Form>
);
}
```
## Fetchers (Non-Navigation Mutations)
Use fetchers when you DON'T want URL changes:
```tsx
import { useFetcher } from "react-router";
function TodoItem({ todo }) {
const fetcher = useFetcher();
const isDeleting = fetcher.state !== "idle";
return (
<li>
<span>{todo.title}</span>
<fetcher.Form method="post" action="/todos/delete">
<input type="hidden" name="id" value={todo.id} />
<button type="submit" disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</fetcher.Form>
</li>
);
}
```
## Optimistic UI with Fetchers
```tsx
function Component() {
const data = useLoaderData();
const fetcher = useFetcher();
// Show optimistic state while submitting
const title = fetcher.formData?.get("title") || data.title;
return (
<div>
<h1>{title}</h1>
<fetcher.Form method="post">
<input type="text" name="title" />
{fetcher.state !== "idle" && <p>Saving...</p>}
</fetcher.Form>
</div>
);
}
```
## Fetcher for Data Loading (Combobox)
```tsx
function UserSearchCombobox() {
const fetcher = useFetcher<typeof loader>();
return (
<div>
<fetcher.Form method="get" action="/search-users">
<input
type="text"
name="q"
onChange={(e) => fetcher.submit(e.currentTarget.form)}
/>
</fetcher.Form>
{fetcher.data && (
<ul style={{ opacity: fetcher.state === "idle" ? 1 : 0.25 }}>
{fetcher.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}
```
## Optimistic List Updates
```tsx
function TodoList() {
const { todos } = useLoaderData();
const fetcher = useFetcher();
const displayedTodos = todos.filter(todo => {
const isDeleting = fetcher.formData?.get("id") === todo.id;
return !isDeleting;
});
return (
<ul>
{displayedTodos.map(todo => (
<li key={todo.id}>
{todo.title}
<fetcher.Form method="post" action="/todos/delete">
<input type="hidden" name="id" value={todo.id} />
<button type="submit">Delete</button>
</fetcher.Form>
</li>
))}
</ul>
);
}
```
FILE:references/advanced.md
# Advanced Patterns
## Contents
- [Error Boundaries](#error-boundaries)
- [Root Error Boundary (Required)](#root-error-boundary-required)
- [Throwing Errors in Loaders](#throwing-errors-in-loaders)
- [Nested Error Boundaries](#nested-error-boundaries)
- [Protected Routes](#protected-routes)
- [Component-Based Protection](#component-based-protection)
- [Middleware (Framework Mode)](#middleware-framework-mode)
- [Lazy Loading / Code Splitting](#lazy-loading--code-splitting)
- [Data Mode Lazy Loading](#data-mode-lazy-loading)
- [Declarative Mode Lazy Loading](#declarative-mode-lazy-loading)
- [Common Route Patterns](#common-route-patterns)
- [Optional Segments](#optional-segments)
- [Catch-All / Splat Routes](#catch-all--splat-routes)
- [Multiple Params](#multiple-params)
- [Index vs Path Routes](#index-vs-path-routes)
---
## Error Boundaries
### Root Error Boundary (Required)
```tsx
import { useRouteError, isRouteErrorResponse } from "react-router";
function RootErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</>
);
} else if (error instanceof Error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<pre>{error.stack}</pre>
</div>
);
} else {
return <h1>Unknown Error</h1>;
}
}
createBrowserRouter([
{
path: "/",
ErrorBoundary: RootErrorBoundary,
Component: Root,
},
]);
```
### Throwing Errors in Loaders
```tsx
import { data } from "react-router";
export async function loader({ params }) {
const record = await db.getRecord(params.id);
if (!record) {
throw data("Record Not Found", { status: 404 });
}
return record;
}
```
### Nested Error Boundaries
```tsx
createBrowserRouter([
{
path: "/app",
ErrorBoundary: AppErrorBoundary,
children: [
{
path: "invoices/:id",
ErrorBoundary: InvoiceErrorBoundary,
Component: Invoice,
},
],
},
]);
```
## Protected Routes
### Component-Based Protection
```tsx
import { Navigate, useLocation } from "react-router";
function RequireAuth({ children }: { children: JSX.Element }) {
const auth = useAuth();
const location = useLocation();
if (!auth.user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// Route configuration
{
path: "/protected",
element: (
<RequireAuth>
<ProtectedPage />
</RequireAuth>
),
}
// In login handler - redirect back
function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
function handleLogin() {
auth.signin(() => {
navigate(from, { replace: true });
});
}
}
```
### Middleware (Framework Mode Only)
Middleware requires Framework Mode and the `future.v8_middleware` flag. Export middleware from route modules:
```tsx
// app/routes/dashboard.tsx (Framework Mode)
import { redirect, createContext } from "react-router";
export const userContext = createContext<User | null>(null);
export const middleware = [
async function authMiddleware({ request, context }, next) {
const userId = getUserId(request);
if (!userId) {
throw redirect("/login");
}
const user = await getUserById(userId);
context.set(userContext, user);
return next();
},
];
export async function loader({ context }: Route.LoaderArgs) {
const user = context.get(userContext);
return { user };
}
```
Note: Middleware is NOT available in Data Mode (createBrowserRouter). Use loaders for auth checks in Data Mode.
## Lazy Loading / Code Splitting
### Data Mode Lazy Loading
```tsx
createBrowserRouter([
{
path: "/app",
lazy: async () => {
const [Component, loader] = await Promise.all([
import("./app"),
import("./app-loader"),
]);
return { Component, loader };
},
},
]);
```
### Declarative Mode Lazy Loading
```tsx
import React from "react";
const About = React.lazy(() => import("./pages/About"));
<Routes>
<Route
path="about"
element={
<React.Suspense fallback={<>Loading...</>}>
<About />
</React.Suspense>
}
/>
</Routes>
```
## Common Route Patterns
### Optional Segments
```tsx
{ path: ":lang?/categories" } // Optional dynamic segment
{ path: "users/:userId/edit?" } // Optional static segment at end
```
### Catch-All / Splat Routes
```tsx
{ path: "files/*" }
// Access splat in loader
loader: ({ params }) => {
const filePath = params["*"]; // "path/to/file.txt"
}
```
### Multiple Params
```tsx
{ path: "users/:userId/posts/:postId" }
// params.userId, params.postId available in loader/component
```
## Index vs Path Routes
```tsx
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
// Index route - renders when parent path matches exactly
{ index: true, Component: DashboardHome },
// Path route - renders at parent + path
{ path: "settings", Component: Settings },
{ path: "profile", Component: Profile },
],
},
]);
```
**Index renders at**: `/dashboard`
**Settings renders at**: `/dashboard/settings`
FILE:references/loaders.md
# Data Loading Patterns
## Contents
- [Basic Loader](#basic-loader)
- [Parallel Data Loading](#parallel-data-loading)
- [Search Params in Loaders](#search-params-in-loaders)
- [useSearchParams Hook](#usesearchparams-hook)
- [Revalidation Control](#revalidation-control)
- [Framework Mode Loaders](#framework-mode-loaders)
---
## Basic Loader
```tsx
{
path: "/teams/:teamId",
loader: async ({ params, request }) => {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const team = await fetchTeam(params.teamId, query);
return { team, name: team.name };
},
Component: Team,
}
function Team() {
const data = useLoaderData();
return <h1>{data.name}</h1>;
}
```
## Parallel Data Loading
Nested routes load data in parallel automatically:
```tsx
createBrowserRouter([
{
path: "/",
loader: rootLoader, // Loads in parallel
children: [
{
path: "project/:id",
loader: projectLoader, // Loads in parallel with rootLoader
},
],
},
]);
```
## Search Params in Loaders
```tsx
{
path: "/search",
loader: async ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const page = url.searchParams.get("page") || "1";
return { results: await search(query, parseInt(page)) };
},
}
function SearchPage() {
const { results } = useLoaderData();
return (
<Form method="get">
<input type="text" name="q" />
<button type="submit">Search</button>
</Form>
);
}
```
## useSearchParams Hook
```tsx
import { useSearchParams } from "react-router";
function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get("q");
return (
<input
value={query || ""}
onChange={(e) => setSearchParams({ q: e.target.value })}
/>
);
}
```
## Revalidation Control
```tsx
function shouldRevalidate({ currentUrl, nextUrl, formAction }) {
return currentUrl.pathname !== nextUrl.pathname;
}
createBrowserRouter([
{
path: "/data",
shouldRevalidate,
loader: dataLoader,
},
]);
```
## Framework Mode Loaders
```tsx
// product.tsx
import { Route } from "./+types/product";
export async function loader({ params }: Route.LoaderArgs) {
const product = await getProduct(params.pid);
return { product };
}
export default function Product({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.product.name}</div>;
}
```
FILE:references/navigation.md
# Navigation Patterns
## Contents
- [NavLink (Active Styling)](#navlink-active-styling)
- [Link (No Active Styling)](#link-no-active-styling)
- [Programmatic Navigation](#programmatic-navigation)
- [Redirect in Loaders](#redirect-in-loaders)
- [Pending UI (Navigation State)](#pending-ui-navigation-state)
- [Form Submission State](#form-submission-state)
- [Index Routes](#index-routes)
- [Layout Routes (No Path)](#layout-routes-no-path)
- [Navigate Component](#navigate-component)
---
## NavLink (Active Styling)
```tsx
import { NavLink } from "react-router";
<NavLink to="/messages" end>
Messages
</NavLink>
// CSS styling
a.active { color: red; }
a.pending { animation: pulse 1s infinite; }
// Callback styling
<NavLink
to="/messages"
className={({ isActive, isPending }) =>
isActive ? "active" : isPending ? "pending" : ""
}
>
Messages
</NavLink>
```
## Link (No Active Styling)
```tsx
import { Link } from "react-router";
<Link to="/login">Login</Link>
<Link to={{ pathname: "/search", search: "?q=term" }}>Search</Link>
```
## Programmatic Navigation
```tsx
import { useNavigate } from "react-router";
function Component() {
const navigate = useNavigate();
// Use sparingly - only for non-user-initiated navigation
useEffect(() => {
if (inactivityTimeout) {
navigate("/logout");
}
}, [inactivityTimeout]);
// Or with options
navigate("/dashboard", { replace: true });
navigate(-1); // Go back
}
```
## Redirect in Loaders
```tsx
import { redirect } from "react-router";
export async function loader({ request }) {
const user = await getUser(request);
if (!user) {
return redirect("/login");
}
return { user };
}
```
## Pending UI (Navigation State)
```tsx
import { useNavigation } from "react-router";
function Root() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
return (
<div>
{isNavigating && <GlobalSpinner />}
<Outlet />
</div>
);
}
```
## Form Submission State
```tsx
function Component() {
const navigation = useNavigation();
const isSubmitting = navigation.formAction === "/recipes/new";
return (
<Form method="post" action="/recipes/new">
<button type="submit">
{isSubmitting ? "Saving..." : "Create Recipe"}
</button>
</Form>
);
}
```
## Index Routes
```tsx
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
{ index: true, Component: DashboardHome }, // Renders at /dashboard
{ path: "settings", Component: Settings }, // Renders at /dashboard/settings
],
},
]);
```
## Layout Routes (No Path)
```tsx
createBrowserRouter([
{
Component: MarketingLayout, // No path, just layout wrapper
children: [
{ index: true, Component: Home },
{ path: "contact", Component: Contact },
],
},
]);
```
## Navigate Component
```tsx
import { Navigate, useLocation } from "react-router";
function RequireAuth({ children }) {
const auth = useAuth();
const location = useLocation();
if (!auth.user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
```
Reviews React Router code for proper data loading, mutations, error handling, and navigation patterns. Use when reviewing React Router v6.4+ code, loaders, a...
---
name: react-router-code-review
description: Reviews React Router code for proper data loading, mutations, error handling, and navigation patterns. Use when reviewing React Router v6.4+ code, loaders, actions, or navigation logic.
---
# React Router Code Review
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| useEffect for data, missing loaders, params | [references/data-loading.md](references/data-loading.md) |
| Form vs useFetcher, action patterns | [references/mutations.md](references/mutations.md) |
| Missing error boundaries, errorElement | [references/error-handling.md](references/error-handling.md) |
| navigate() vs Link, pending states | [references/navigation.md](references/navigation.md) |
## Review Checklist
- [ ] Data loaded via `loader` not `useEffect`
- [ ] Route params accessed type-safely with validation
- [ ] Using `defer()` for parallel data fetching when appropriate
- [ ] Mutations use `<Form>` or `useFetcher` not manual fetch
- [ ] Actions handle both success and error cases
- [ ] Error boundaries with `errorElement` on routes
- [ ] Using `isRouteErrorResponse()` to check error types
- [ ] Navigation uses `<Link>` over `navigate()` where possible
- [ ] Pending states shown via `useNavigation()` or `fetcher.state`
- [ ] No navigation in render (only in effects or handlers)
## Valid Patterns (Do NOT Flag)
These patterns are correct React Router usage - do not report as issues:
- **useEffect for client-only data** - Loaders run server-side; localStorage, window dimensions, and browser APIs must use useEffect
- **navigate() in event handlers** - Link is for declarative navigation; navigate() is correct for imperative navigation in callbacks/handlers
- **Type annotation on loader data** - `useLoaderData<typeof loader>()` is a type annotation, not a type assertion
- **Empty errorElement at route level** - Route may intentionally rely on parent error boundary
- **Form without action prop** - Posts to current URL by convention; explicit action is optional
- **loader returning null** - Valid when data may not exist; null is a legitimate loader return value
- **Using fetcher.data without checking fetcher.state** - May be intentional when stale data is acceptable during revalidation
## Context-Sensitive Rules
Only flag these issues when the specific context applies:
| Issue | Flag ONLY IF |
|-------|--------------|
| Missing loader | Data is available server-side (not client-only) |
| useEffect for data fetching | Data is NOT client-only (localStorage, browser APIs, window size) |
| Missing errorElement | No parent route in the hierarchy has an error boundary |
| navigate() instead of Link | Navigation is NOT triggered by an event handler or conditional logic |
## Gates (before reporting any finding)
Run in order. **Pass each gate with evidence** (paths, line refs, or a one-line quote from code)—not intuition alone.
### Gate 1 — Scope the route surface
**Pass when:** You have repo path(s) to the route module, `routes` config entry, or layout that owns the behavior under review (write them in your notes before flagging).
### Gate 2 — Context-sensitive match
**Pass when:** For every issue that maps to **Context-Sensitive Rules**, the **Flag ONLY IF** condition is satisfied with a one-line rationale tied to the code; for other checklist items, you have a concrete code citation (path + line or short excerpt).
### Gate 3 — Non-issue patterns
**Pass when:** The behavior is not covered by **Valid Patterns (Do NOT Flag)** for that category.
### Gate 4 — Verification protocol
Load and follow [review-verification-protocol](../review-verification-protocol/SKILL.md). **Pass when:** Its pre-report checklist (and any issue-type subsection that applies) is complete for each finding you will output.
## When to Load References
- Reviewing data fetching code → data-loading.md
- Reviewing forms or mutations → mutations.md
- Reviewing error handling → error-handling.md
- Reviewing navigation logic → navigation.md
## Review Questions
1. Is data loaded in loaders instead of effects?
2. Are mutations using Form/action patterns?
3. Are there error boundaries at appropriate route levels?
4. Is navigation declarative with Link components?
5. Are pending states properly handled?
FILE:references/data-loading.md
# Data Loading
## Critical Anti-Patterns
### 1. Using useEffect Instead of Loaders
**Problem**: Race conditions, loading states, unnecessary client-side fetching.
```tsx
// BAD - Loading data in useEffect
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const { userId } = useParams();
useEffect(() => {
setLoading(true);
fetch(`/api/users/userId`)
.then(r => r.json())
.then(setUser)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
// GOOD - Using loader
// Route definition
{
path: "users/:userId",
element: <UserProfile />,
loader: async ({ params }) => {
const response = await fetch(`/api/users/params.userId`);
if (!response.ok) throw new Response("Not Found", { status: 404 });
return response.json();
}
}
// Component
function UserProfile() {
const user = useLoaderData<User>();
return <div>{user.name}</div>;
}
```
### 2. Unsafe Route Params Access
**Problem**: Runtime errors from missing or invalid params.
```tsx
// BAD - No validation
const loader = async ({ params }) => {
// params.userId could be undefined!
return fetch(`/api/users/params.userId`);
};
// GOOD - Validate params
const loader = async ({ params }) => {
const userId = params.userId;
if (!userId) {
throw new Response("User ID required", { status: 400 });
}
// Optional: validate format
if (!/^\d+$/.test(userId)) {
throw new Response("Invalid user ID", { status: 400 });
}
return fetch(`/api/users/userId`);
};
// BETTER - Type-safe with zod
import { z } from "zod";
const ParamsSchema = z.object({
userId: z.string().regex(/^\d+$/)
});
const loader = async ({ params }) => {
const { userId } = ParamsSchema.parse(params);
return fetch(`/api/users/userId`);
};
```
### 3. Sequential Data Fetching
**Problem**: Slow page loads when data can be fetched in parallel.
```tsx
// BAD - Sequential fetching
const loader = async ({ params }) => {
const user = await fetchUser(params.userId);
const posts = await fetchPosts(params.userId);
const comments = await fetchComments(params.userId);
return { user, posts, comments };
};
// GOOD - Parallel fetching
const loader = async ({ params }) => {
const [user, posts, comments] = await Promise.all([
fetchUser(params.userId),
fetchPosts(params.userId),
fetchComments(params.userId),
]);
return { user, posts, comments };
};
// BETTER - Using defer for progressive loading
import { defer } from "react-router-dom";
const loader = async ({ params }) => {
// Critical data - await it
const user = await fetchUser(params.userId);
// Non-critical data - defer it
return defer({
user,
posts: fetchPosts(params.userId), // Don't await
comments: fetchComments(params.userId), // Don't await
});
};
// Component with Suspense
function UserProfile() {
const { user, posts, comments } = useLoaderData();
return (
<div>
<h1>{user.name}</h1>
<Suspense fallback={<div>Loading posts...</div>}>
<Await resolve={posts}>
{(posts) => <PostList posts={posts} />}
</Await>
</Suspense>
<Suspense fallback={<div>Loading comments...</div>}>
<Await resolve={comments}>
{(comments) => <CommentList comments={comments} />}
</Await>
</Suspense>
</div>
);
}
```
### 4. Not Revalidating After Mutations
**Problem**: Stale data after updates, manual cache invalidation.
```tsx
// BAD - Manual refetch
function UserProfile() {
const user = useLoaderData<User>();
const [localUser, setLocalUser] = useState(user);
const handleUpdate = async (data) => {
await fetch(`/api/users/user.id`, {
method: "PATCH",
body: JSON.stringify(data),
});
// Manual refetch - easy to forget!
const updated = await fetch(`/api/users/user.id`).then(r => r.json());
setLocalUser(updated);
};
return <UserForm user={localUser} onSubmit={handleUpdate} />;
}
// GOOD - Automatic revalidation
// Action automatically triggers loader revalidation
const action = async ({ request, params }) => {
const formData = await request.formData();
const response = await fetch(`/api/users/params.userId`, {
method: "PATCH",
body: formData,
});
if (!response.ok) throw new Response("Update failed", { status: 400 });
return redirect(`/users/params.userId`);
};
function UserProfile() {
const user = useLoaderData<User>();
// No useState needed - loader data auto-revalidates
return <UserForm user={user} />;
}
```
### 5. Missing Error Handling in Loaders
**Problem**: Uncaught errors, poor user experience.
```tsx
// BAD - No error handling
const loader = async ({ params }) => {
const response = await fetch(`/api/users/params.userId`);
return response.json(); // What if response is 404 or 500?
};
// GOOD - Proper error handling
const loader = async ({ params }) => {
const response = await fetch(`/api/users/params.userId`);
if (!response.ok) {
throw new Response("User not found", {
status: response.status,
statusText: response.statusText
});
}
return response.json();
};
// BETTER - Detailed error responses
const loader = async ({ params }) => {
try {
const response = await fetch(`/api/users/params.userId`);
if (response.status === 404) {
throw new Response("User not found", { status: 404 });
}
if (response.status === 403) {
throw new Response("You don't have permission to view this user", {
status: 403
});
}
if (!response.ok) {
throw new Response("Failed to load user", {
status: response.status
});
}
return response.json();
} catch (error) {
if (error instanceof Response) throw error;
// Network error or other unexpected error
throw new Response("Network error - please try again", {
status: 503
});
}
};
```
### 6. Accessing Search Params Without URLSearchParams
**Problem**: Manual string parsing, inconsistent handling.
```tsx
// BAD - Manual parsing
const loader = async ({ request }) => {
const url = new URL(request.url);
const search = url.search.slice(1); // Remove '?'
const page = search.split('&').find(p => p.startsWith('page='))?.split('=')[1] || '1';
return fetchUsers(parseInt(page));
};
// GOOD - Using URLSearchParams
const loader = async ({ request }) => {
const url = new URL(request.url);
const page = url.searchParams.get('page') || '1';
return fetchUsers(parseInt(page, 10));
};
// BETTER - Type-safe search params
import { z } from "zod";
const SearchParamsSchema = z.object({
page: z.coerce.number().min(1).default(1),
sort: z.enum(['name', 'date', 'popular']).default('name'),
filter: z.string().optional(),
});
const loader = async ({ request }) => {
const url = new URL(request.url);
const rawParams = Object.fromEntries(url.searchParams);
const { page, sort, filter } = SearchParamsSchema.parse(rawParams);
return fetchUsers({ page, sort, filter });
};
```
## Review Questions
1. Is all route data loaded via loaders, not useEffect?
2. Are route params validated before use?
3. Are independent data fetches executed in parallel?
4. Is defer() used for non-critical data?
5. Do loaders throw proper Response objects on errors?
6. Are search params parsed with URLSearchParams?
FILE:references/error-handling.md
# Error Handling
## Critical Anti-Patterns
### 1. Missing Error Boundaries
**Problem**: Entire app crashes on route errors, poor UX.
```tsx
// BAD - No error handling
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
children: [
{
path: "users/:userId",
element: <UserProfile />,
loader: async ({ params }) => {
// If this fails, entire app shows error
return fetch(`/api/users/params.userId`).then(r => r.json());
}
}
]
}
]);
// GOOD - Error boundaries at route level
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <RootErrorBoundary />, // Catch all errors
children: [
{
path: "users/:userId",
element: <UserProfile />,
errorElement: <UserErrorBoundary />, // Scoped error handling
loader: async ({ params }) => {
const response = await fetch(`/api/users/params.userId`);
if (!response.ok) {
throw new Response("User not found", { status: 404 });
}
return response.json();
}
}
]
}
]);
// Error boundary component
function UserErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return <div>User not found</div>;
}
if (error.status === 403) {
return <div>You don't have permission to view this user</div>;
}
}
return <div>Something went wrong loading this user</div>;
}
```
### 2. Not Using isRouteErrorResponse
**Problem**: Unsafe error access, runtime errors in error handlers.
```tsx
// BAD - Unsafe error access
function ErrorBoundary() {
const error = useRouteError();
// error might not have these properties!
return (
<div>
<h1>Error {error.status}</h1>
<p>{error.statusText}</p>
<p>{error.data}</p>
</div>
);
}
// GOOD - Type-safe error checking
import { isRouteErrorResponse } from 'react-router-dom';
function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
// Now we know error has status, statusText, data
return (
<div>
<h1>Error {error.status}</h1>
<p>{error.statusText}</p>
{typeof error.data === 'string' && <p>{error.data}</p>}
</div>
);
}
if (error instanceof Error) {
return (
<div>
<h1>Unexpected Error</h1>
<p>{error.message}</p>
{import.meta.env.DEV && <pre>{error.stack}</pre>}
</div>
);
}
return <div>An unknown error occurred</div>;
}
```
### 3. Throwing Raw Errors Instead of Responses
**Problem**: Missing status codes, inconsistent error format.
```tsx
// BAD - Throwing raw errors
const loader = async ({ params }) => {
const user = await db.user.findUnique({
where: { id: params.userId }
});
if (!user) {
throw new Error('User not found'); // No status code!
}
if (!user.isPublic && !currentUser) {
throw new Error('Unauthorized'); // Should be 403, not 500!
}
return user;
};
// GOOD - Throwing Response objects
const loader = async ({ params }) => {
const user = await db.user.findUnique({
where: { id: params.userId }
});
if (!user) {
throw new Response('User not found', { status: 404 });
}
if (!user.isPublic && !currentUser) {
throw new Response('You must be logged in to view this profile', {
status: 403
});
}
return user;
};
// BETTER - Using json() helper for structured errors
import { json } from 'react-router-dom';
const loader = async ({ params }) => {
const user = await db.user.findUnique({
where: { id: params.userId }
});
if (!user) {
throw json(
{ message: 'User not found', userId: params.userId },
{ status: 404 }
);
}
if (!user.isPublic && !currentUser) {
throw json(
{ message: 'Login required', redirectTo: `/login?return=/users/params.userId` },
{ status: 403 }
);
}
return user;
};
// Error boundary using structured error
function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 403 && error.data?.redirectTo) {
return (
<div>
<p>{error.data.message}</p>
<Link to={error.data.redirectTo}>Log in</Link>
</div>
);
}
if (error.status === 404) {
return <div>{error.data.message}</div>;
}
}
return <div>Something went wrong</div>;
}
```
### 4. Not Differentiating Error Types
**Problem**: Same handling for different errors, poor UX.
```tsx
// BAD - Generic error handling
function ErrorBoundary() {
const error = useRouteError();
// Everything gets same treatment
return <div>Error: {String(error)}</div>;
}
// GOOD - Specific handling per error type
function ErrorBoundary() {
const error = useRouteError();
// Network/fetch errors
if (error instanceof TypeError && error.message.includes('fetch')) {
return (
<div className="error">
<h1>Network Error</h1>
<p>Unable to connect to the server. Please check your connection.</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
);
}
// Route errors
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div className="error">
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<Link to="/">Go home</Link>
</div>
);
}
if (error.status === 403) {
return (
<div className="error">
<h1>Access Denied</h1>
<p>You don't have permission to access this resource.</p>
<Link to="/login">Log in</Link>
</div>
);
}
if (error.status === 500) {
return (
<div className="error">
<h1>Server Error</h1>
<p>Something went wrong on our end. Please try again later.</p>
</div>
);
}
// Generic HTTP error
return (
<div className="error">
<h1>Error {error.status}</h1>
<p>{error.statusText}</p>
</div>
);
}
// JavaScript errors
if (error instanceof Error) {
return (
<div className="error">
<h1>Unexpected Error</h1>
<p>{error.message}</p>
{import.meta.env.DEV && (
<details>
<summary>Stack trace</summary>
<pre>{error.stack}</pre>
</details>
)}
</div>
);
}
// Unknown error
return (
<div className="error">
<h1>Unknown Error</h1>
<p>An unexpected error occurred.</p>
</div>
);
}
```
### 5. Missing Root Error Boundary
**Problem**: Uncaught errors bubble to browser, blank screen.
```tsx
// BAD - No root error boundary
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
children: [
// children routes...
]
}
]);
// GOOD - Root error boundary catches everything
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <RootErrorBoundary />,
children: [
{
path: "users",
element: <Users />,
errorElement: <UsersErrorBoundary />, // Scoped
},
// other routes...
]
}
]);
// Root error boundary with full-page layout
function RootErrorBoundary() {
const error = useRouteError();
return (
<html lang="en">
<head>
<title>Error - My App</title>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
</head>
<body>
<div className="error-page">
<header>
<Link to="/">
<img src="/logo.png" alt="My App" />
</Link>
</header>
<main>
{isRouteErrorResponse(error) ? (
<>
<h1>Error {error.status}</h1>
<p>{error.statusText}</p>
</>
) : error instanceof Error ? (
<>
<h1>Unexpected Error</h1>
<p>{error.message}</p>
</>
) : (
<h1>Unknown Error</h1>
)}
<Link to="/">Go back home</Link>
</main>
</div>
</body>
</html>
);
}
```
### 6. Not Logging Errors
**Problem**: No visibility into production errors, hard to debug.
```tsx
// BAD - Silent errors
function ErrorBoundary() {
const error = useRouteError();
return <div>Error occurred</div>;
}
// GOOD - Errors logged to monitoring service
function ErrorBoundary() {
const error = useRouteError();
React.useEffect(() => {
// Log to error tracking service
if (isRouteErrorResponse(error)) {
logError({
type: 'RouteError',
status: error.status,
statusText: error.statusText,
data: error.data,
});
} else if (error instanceof Error) {
logError({
type: 'JavaScriptError',
message: error.message,
stack: error.stack,
});
} else {
logError({
type: 'UnknownError',
error: String(error),
});
}
}, [error]);
return <ErrorDisplay error={error} />;
}
// BETTER - Centralized error logging
function useErrorLogging(error: unknown) {
React.useEffect(() => {
// Don't log in development
if (import.meta.env.DEV) return;
// Send to monitoring service (Sentry, etc.)
if (isRouteErrorResponse(error)) {
window.analytics?.track('Route Error', {
status: error.status,
statusText: error.statusText,
path: window.location.pathname,
});
} else if (error instanceof Error) {
window.analytics?.track('JavaScript Error', {
message: error.message,
stack: error.stack,
path: window.location.pathname,
});
}
}, [error]);
}
function ErrorBoundary() {
const error = useRouteError();
useErrorLogging(error);
return <ErrorDisplay error={error} />;
}
```
## Review Questions
1. Does every route have an errorElement?
2. Is isRouteErrorResponse used to check error types?
3. Are loaders/actions throwing Response objects with status codes?
4. Are different error types handled differently?
5. Is there a root error boundary?
6. Are errors logged to a monitoring service?
FILE:references/mutations.md
# Mutations
## Critical Anti-Patterns
### 1. Manual Form Submission with fetch
**Problem**: Missing navigation state, manual revalidation, no progressive enhancement.
```tsx
// BAD - Manual fetch in handler
function CreateUser() {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
const formData = new FormData(e.target);
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData)),
});
if (response.ok) {
navigate('/users');
} else {
alert('Error creating user');
}
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<button disabled={loading}>
{loading ? 'Creating...' : 'Create'}
</button>
</form>
);
}
// GOOD - Using Form and action
// Route definition
{
path: "users/new",
element: <CreateUser />,
action: async ({ request }) => {
const formData = await request.formData();
const response = await fetch('/api/users', {
method: 'POST',
body: formData,
});
if (!response.ok) {
return { error: 'Failed to create user' };
}
return redirect('/users');
}
}
// Component
import { Form, useNavigation, useActionData } from 'react-router-dom';
function CreateUser() {
const navigation = useNavigation();
const actionData = useActionData();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post">
<input name="name" />
{actionData?.error && <div className="error">{actionData.error}</div>}
<button disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create'}
</button>
</Form>
);
}
```
### 2. Using Form When useFetcher is Appropriate
**Problem**: Unnecessary navigation, losing current page state.
```tsx
// BAD - Form causes navigation away from current page
function TodoList() {
const todos = useLoaderData<Todo[]>();
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
<span>{todo.text}</span>
{/* This will navigate away! */}
<Form method="post" action={`/todos/todo.id/toggle`}>
<button>Toggle</button>
</Form>
</div>
))}
</div>
);
}
// GOOD - useFetcher stays on current page
import { useFetcher } from 'react-router-dom';
function TodoList() {
const todos = useLoaderData<Todo[]>();
return (
<div>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</div>
);
}
function TodoItem({ todo }) {
const fetcher = useFetcher();
// Optimistic UI - show state immediately
const isComplete = fetcher.formData
? fetcher.formData.get('complete') === 'true'
: todo.complete;
return (
<div>
<span style={{ textDecoration: isComplete ? 'line-through' : 'none' }}>
{todo.text}
</span>
<fetcher.Form method="post" action={`/todos/todo.id/toggle`}>
<input type="hidden" name="complete" value={String(!isComplete)} />
<button disabled={fetcher.state !== 'idle'}>
{fetcher.state !== 'idle' ? 'Toggling...' : 'Toggle'}
</button>
</fetcher.Form>
</div>
);
}
```
### 3. Not Validating Action Data
**Problem**: Runtime errors, poor error messages.
```tsx
// BAD - No validation
const action = async ({ request }) => {
const formData = await request.formData();
// What if name is missing or invalid?
const name = formData.get('name');
const email = formData.get('email');
await createUser({ name, email });
return redirect('/users');
};
// GOOD - Validation with helpful errors
const action = async ({ request }) => {
const formData = await request.formData();
const name = formData.get('name');
const email = formData.get('email');
const errors = {};
if (!name || typeof name !== 'string' || name.trim().length === 0) {
errors.name = 'Name is required';
}
if (!email || typeof email !== 'string') {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'Invalid email format';
}
if (Object.keys(errors).length > 0) {
return { errors };
}
await createUser({ name, email });
return redirect('/users');
};
// BETTER - Schema validation
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email format'),
});
const action = async ({ request }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
try {
const validated = CreateUserSchema.parse(data);
await createUser(validated);
return redirect('/users');
} catch (error) {
if (error instanceof z.ZodError) {
return {
errors: error.flatten().fieldErrors
};
}
throw error;
}
};
// Component using validation errors
function CreateUser() {
const actionData = useActionData<{ errors?: Record<string, string[]> }>();
return (
<Form method="post">
<div>
<input name="name" />
{actionData?.errors?.name && (
<span className="error">{actionData.errors.name[0]}</span>
)}
</div>
<div>
<input name="email" type="email" />
{actionData?.errors?.email && (
<span className="error">{actionData.errors.email[0]}</span>
)}
</div>
<button>Create User</button>
</Form>
);
}
```
### 4. Missing Optimistic UI
**Problem**: Slow perceived performance, no immediate feedback.
```tsx
// BAD - No optimistic update
function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
const fetcher = useFetcher();
return (
<fetcher.Form method="post" action={`/posts/postId/like`}>
<button>
{/* Only updates after server responds */}
{liked ? '❤️' : '🤍'}
</button>
</fetcher.Form>
);
}
// GOOD - Optimistic UI
function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
const fetcher = useFetcher();
// Show optimistic state immediately
const optimisticLiked = fetcher.formData
? fetcher.formData.get('liked') === 'true'
: liked;
return (
<fetcher.Form method="post" action={`/posts/postId/like`}>
<input type="hidden" name="liked" value={String(!optimisticLiked)} />
<button disabled={fetcher.state !== 'idle'}>
{optimisticLiked ? '❤️' : '🤍'}
</button>
</fetcher.Form>
);
}
// BETTER - Optimistic UI with count
function LikeButton({
postId,
liked,
likeCount
}: {
postId: string;
liked: boolean;
likeCount: number;
}) {
const fetcher = useFetcher();
const optimisticLiked = fetcher.formData
? fetcher.formData.get('liked') === 'true'
: liked;
const optimisticCount = fetcher.formData
? optimisticLiked
? likeCount + 1
: likeCount - 1
: likeCount;
return (
<fetcher.Form method="post" action={`/posts/postId/like`}>
<input type="hidden" name="liked" value={String(!optimisticLiked)} />
<button disabled={fetcher.state !== 'idle'}>
{optimisticLiked ? '❤️' : '🤍'} {optimisticCount}
</button>
</fetcher.Form>
);
}
```
### 5. Not Handling Action Errors
**Problem**: Silent failures, poor error UX.
```tsx
// BAD - No error handling
const action = async ({ request }) => {
const formData = await request.formData();
// If this throws, user sees error boundary
await createUser(Object.fromEntries(formData));
return redirect('/users');
};
// GOOD - Graceful error handling
const action = async ({ request }) => {
const formData = await request.formData();
try {
await createUser(Object.fromEntries(formData));
return redirect('/users');
} catch (error) {
// Return error to show in form, not error boundary
if (error instanceof Error) {
return { error: error.message };
}
return { error: 'An unexpected error occurred' };
}
};
// BETTER - Typed errors with status
const action = async ({ request }) => {
const formData = await request.formData();
try {
await createUser(Object.fromEntries(formData));
return redirect('/users');
} catch (error) {
if (error instanceof Response) {
// API returned error response
const body = await error.json();
return { error: body.message, status: error.status };
}
if (error instanceof Error) {
return { error: error.message };
}
return { error: 'An unexpected error occurred' };
}
};
// Component showing errors
function CreateUser() {
const actionData = useActionData<{ error?: string; status?: number }>();
return (
<div>
{actionData?.error && (
<div className={actionData.status === 400 ? 'warning' : 'error'}>
{actionData.error}
</div>
)}
<Form method="post">
{/* form fields */}
</Form>
</div>
);
}
```
### 6. Action Without Intent
**Problem**: Multiple actions in one endpoint, unclear intent.
```tsx
// BAD - Multiple actions in one action function
const action = async ({ request }) => {
const formData = await request.formData();
const action = formData.get('_action');
if (action === 'create') {
// create logic
} else if (action === 'update') {
// update logic
} else if (action === 'delete') {
// delete logic
}
return redirect('/users');
};
// GOOD - Separate action routes
// Route definition
{
path: "users",
children: [
{
path: "new",
element: <CreateUser />,
action: createUserAction,
},
{
path: ":userId/edit",
element: <EditUser />,
action: updateUserAction,
},
{
path: ":userId/delete",
action: deleteUserAction,
}
]
}
// ACCEPTABLE - Multiple intents with clear intent field
const action = async ({ request }) => {
const formData = await request.formData();
const intent = formData.get('intent');
switch (intent) {
case 'archive':
return handleArchive(formData);
case 'unarchive':
return handleUnarchive(formData);
default:
throw new Response('Invalid intent', { status: 400 });
}
};
// Component making intent clear
<fetcher.Form method="post">
<input type="hidden" name="intent" value="archive" />
<button>Archive</button>
</fetcher.Form>
```
## Review Questions
1. Are mutations using Form/fetcher.Form instead of manual fetch?
2. Is useFetcher used for actions that shouldn't navigate?
3. Are action inputs validated before processing?
4. Are optimistic UI updates shown for immediate feedback?
5. Do actions handle and return errors gracefully?
6. Is action intent clear and single-purpose?
FILE:references/navigation.md
# Navigation
## Critical Anti-Patterns
### 1. Using navigate() Instead of Link
**Problem**: Missing accessibility, no progressive enhancement, can't open in new tab.
```tsx
// BAD - navigate() for user-initiated navigation
function UserCard({ userId }: { userId: string }) {
const navigate = useNavigate();
return (
<div onClick={() => navigate(`/users/userId`)}>
<h3>User {userId}</h3>
</div>
);
}
// Problems:
// - Can't right-click to open in new tab
// - Can't Cmd+Click to open in new tab
// - Screen readers don't know it's a link
// - No keyboard navigation
// GOOD - Use Link for navigation
function UserCard({ userId }: { userId: string }) {
return (
<Link to={`/users/userId`} className="user-card">
<h3>User {userId}</h3>
</Link>
);
}
// Benefits:
// - Right-click works
// - Cmd/Ctrl+Click works
// - Accessible to screen readers
// - Tab navigation works
// - Shows URL on hover
```
### 2. Imperative Navigation in Render
**Problem**: Navigation happens during render, causes infinite loops.
```tsx
// BAD - navigate() during render
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const user = useLoaderData<User | null>();
const navigate = useNavigate();
if (!user) {
navigate('/login'); // BAD: navigate during render!
return null;
}
return <>{children}</>;
}
// GOOD - Navigate in effect
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const user = useLoaderData<User | null>();
const navigate = useNavigate();
React.useEffect(() => {
if (!user) {
navigate('/login');
}
}, [user, navigate]);
if (!user) {
return <div>Redirecting...</div>;
}
return <>{children}</>;
}
// BETTER - Handle in loader
const loader = async ({ request }) => {
const user = await getUser(request);
if (!user) {
// Redirect before component renders
throw redirect('/login');
}
return user;
};
```
### 3. Missing Pending UI States
**Problem**: No feedback during navigation, feels broken.
```tsx
// BAD - No loading state
function UserList() {
const users = useLoaderData<User[]>();
return (
<div>
<h1>Users</h1>
<ul>
{users.map(user => (
<li key={user.id}>
<Link to={`/users/user.id`}>{user.name}</Link>
</li>
))}
</ul>
</div>
);
}
// User clicks link, nothing happens for 2 seconds, then page changes
// Bad UX!
// GOOD - Show loading state
import { useNavigation } from 'react-router-dom';
function UserList() {
const users = useLoaderData<User[]>();
const navigation = useNavigation();
return (
<div>
<h1>Users</h1>
{navigation.state === 'loading' && (
<div className="loading-bar" />
)}
<ul className={navigation.state === 'loading' ? 'opacity-50' : ''}>
{users.map(user => (
<li key={user.id}>
<Link to={`/users/user.id`}>{user.name}</Link>
</li>
))}
</ul>
</div>
);
}
// BETTER - Global loading indicator
function Root() {
const navigation = useNavigation();
return (
<div>
{navigation.state !== 'idle' && (
<div className="global-loading-bar">
Loading...
</div>
)}
<nav>
<Link to="/">Home</Link>
<Link to="/users">Users</Link>
</nav>
<main className={navigation.state === 'loading' ? 'loading' : ''}>
<Outlet />
</main>
</div>
);
}
```
### 4. Not Using NavLink for Active Styles
**Problem**: Manual active state management, inconsistent UI.
```tsx
// BAD - Manual active state
function Navigation() {
const location = useLocation();
return (
<nav>
<Link
to="/"
className={location.pathname === '/' ? 'active' : ''}
>
Home
</Link>
<Link
to="/users"
className={location.pathname.startsWith('/users') ? 'active' : ''}
>
Users
</Link>
<Link
to="/settings"
className={location.pathname === '/settings' ? 'active' : ''}
>
Settings
</Link>
</nav>
);
}
// GOOD - NavLink with className function
import { NavLink } from 'react-router-dom';
function Navigation() {
return (
<nav>
<NavLink
to="/"
end // Only match exact path
className={({ isActive }) => isActive ? 'active' : ''}
>
Home
</NavLink>
<NavLink
to="/users"
className={({ isActive }) => isActive ? 'active' : ''}
>
Users
</NavLink>
<NavLink
to="/settings"
className={({ isActive }) => isActive ? 'active' : ''}
>
Settings
</NavLink>
</nav>
);
}
// BETTER - NavLink with style function
function Navigation() {
const activeStyle = {
fontWeight: 'bold',
color: 'var(--primary)',
borderBottom: '2px solid var(--primary)',
};
return (
<nav>
<NavLink
to="/"
end
style={({ isActive }) => isActive ? activeStyle : undefined}
>
Home
</NavLink>
<NavLink
to="/users"
style={({ isActive }) => isActive ? activeStyle : undefined}
>
Users
</NavLink>
</nav>
);
}
```
### 5. Not Preserving Search Params on Navigation
**Problem**: Lost state, broken URLs, poor UX.
```tsx
// BAD - Navigation loses search params
function UserFilters() {
return (
<div>
{/* Current URL: /users?sort=name&filter=active */}
{/* After clicking, URL becomes: /users?sort=date (filter lost!) */}
<Link to="/users?sort=date">Sort by date</Link>
</div>
);
}
// GOOD - Preserve existing search params
function UserFilters() {
const [searchParams] = useSearchParams();
const getSortLink = (sort: string) => {
const params = new URLSearchParams(searchParams);
params.set('sort', sort);
return `/users?params.toString()`;
};
return (
<div>
<Link to={getSortLink('date')}>Sort by date</Link>
<Link to={getSortLink('name')}>Sort by name</Link>
</div>
);
}
// BETTER - Reusable hook
function useSearchParamsWithPreserve() {
const [searchParams, setSearchParams] = useSearchParams();
const updateSearchParam = React.useCallback(
(key: string, value: string | null) => {
setSearchParams(prev => {
const params = new URLSearchParams(prev);
if (value === null) {
params.delete(key);
} else {
params.set(key, value);
}
return params;
});
},
[setSearchParams]
);
return [searchParams, updateSearchParam] as const;
}
function UserFilters() {
const [searchParams, updateSearchParam] = useSearchParamsWithPreserve();
return (
<div>
<button onClick={() => updateSearchParam('sort', 'date')}>
Sort by date
</button>
<button onClick={() => updateSearchParam('sort', 'name')}>
Sort by name
</button>
</div>
);
}
```
### 6. Blocking Navigation Without Confirmation
**Problem**: Lost unsaved changes, data loss.
```tsx
// BAD - No confirmation on navigation
function EditUser() {
const [formData, setFormData] = useState({});
const [isDirty, setIsDirty] = useState(false);
// User can navigate away and lose changes!
return (
<form>
<input
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
setIsDirty(true);
}}
/>
</form>
);
}
// GOOD - Block navigation with confirmation
import { useBlocker } from 'react-router-dom';
function EditUser() {
const [formData, setFormData] = useState({});
const [isDirty, setIsDirty] = useState(false);
// Block navigation if form is dirty
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname
);
return (
<>
{blocker.state === 'blocked' && (
<div className="modal">
<p>You have unsaved changes. Are you sure you want to leave?</p>
<button onClick={() => blocker.proceed()}>Leave</button>
<button onClick={() => blocker.reset()}>Stay</button>
</div>
)}
<form>
<input
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
setIsDirty(true);
}}
/>
</form>
</>
);
}
// BETTER - Also handle browser navigation
function EditUser() {
const [formData, setFormData] = useState({});
const [isDirty, setIsDirty] = useState(false);
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname
);
// Handle browser back/forward, refresh, close
React.useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault();
e.returnValue = ''; // Required for Chrome
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [isDirty]);
return (
<>
{blocker.state === 'blocked' && (
<ConfirmationModal
onConfirm={() => blocker.proceed()}
onCancel={() => blocker.reset()}
/>
)}
<form>{/* form fields */}</form>
</>
);
}
```
### 7. Not Using Relative Paths
**Problem**: Brittle routes, hard to refactor.
```tsx
// BAD - Absolute paths everywhere
// Route: /projects/:projectId/tasks/:taskId
function TaskDetail() {
const { projectId, taskId } = useParams();
return (
<div>
<Link to={`/projects/projectId/tasks`}>Back to tasks</Link>
<Link to={`/projects/projectId/tasks/taskId/edit`}>Edit</Link>
<Link to={`/projects/projectId`}>Back to project</Link>
</div>
);
}
// If you change the route structure, all these links break!
// GOOD - Relative paths
function TaskDetail() {
return (
<div>
{/* Go up one level */}
<Link to="..">Back to tasks</Link>
{/* Stay at current level, append /edit */}
<Link to="edit">Edit</Link>
{/* Go up two levels */}
<Link to="../..">Back to project</Link>
</div>
);
}
// BETTER - Mix relative and absolute as appropriate
function TaskDetail() {
const { projectId } = useParams();
return (
<div>
{/* Relative for sibling/parent routes */}
<Link to="..">Back to tasks</Link>
<Link to="edit">Edit</Link>
{/* Absolute for cross-section navigation */}
<Link to="/">Home</Link>
<Link to="/settings">Settings</Link>
{/* Template when you need params */}
<Link to={`/projects/projectId/settings`}>Project Settings</Link>
</div>
);
}
```
## Review Questions
1. Are Links used for navigation instead of navigate()?
2. Is navigate() only called in effects or handlers, not render?
3. Are pending states shown during navigation?
4. Is NavLink used for navigation with active states?
5. Are search params preserved when updating URLs?
6. Are unsaved changes protected with useBlocker?
7. Are relative paths used within route hierarchies?
React Flow (@xyflow/react) for workflow visualization with custom nodes and edges. Use when building graph visualizations, creating custom workflow nodes, im...
---
name: react-flow
description: React Flow (@xyflow/react) for workflow visualization with custom nodes and edges. Use when building graph visualizations, creating custom workflow nodes, implementing edge labels, or controlling viewport. Triggers on ReactFlow, @xyflow/react, Handle, NodeProps, EdgeProps, useReactFlow, fitView.
---
# React Flow
React Flow (@xyflow/react) is a library for building node-based graphs, workflow editors, and interactive diagrams. It provides a highly customizable framework for creating visual programming interfaces, process flows, and network visualizations.
## Quick Start
### Installation
```bash
pnpm add @xyflow/react
```
### Basic Setup
```typescript
import { ReactFlow, Node, Edge, Background, Controls, MiniMap } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
const initialNodes: Node[] = [
{
id: '1',
type: 'input',
data: { label: 'Input Node' },
position: { x: 250, y: 5 },
},
{
id: '2',
data: { label: 'Default Node' },
position: { x: 100, y: 100 },
},
{
id: '3',
type: 'output',
data: { label: 'Output Node' },
position: { x: 400, y: 100 },
},
];
const initialEdges: Edge[] = [
{ id: 'e1-2', source: '1', target: '2', animated: true },
{ id: 'e2-3', source: '2', target: '3' },
];
function Flow() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<ReactFlow nodes={initialNodes} edges={initialEdges}>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</div>
);
}
export default Flow;
```
## Core Concepts
### Nodes
Nodes are the building blocks of the graph. Each node has:
- `id`: Unique identifier
- `type`: Node type (built-in or custom)
- `position`: { x, y } coordinates
- `data`: Custom data object
```typescript
import { Node } from '@xyflow/react';
const node: Node = {
id: 'node-1',
type: 'default',
position: { x: 100, y: 100 },
data: { label: 'Node Label' },
style: { background: '#D6D5E6' },
className: 'custom-node',
};
```
Built-in node types:
- `default`: Standard node
- `input`: No target handles
- `output`: No source handles
- `group`: Container for other nodes
### Edges
Edges connect nodes. Each edge requires:
- `id`: Unique identifier
- `source`: Source node ID
- `target`: Target node ID
```typescript
import { Edge } from '@xyflow/react';
const edge: Edge = {
id: 'e1-2',
source: '1',
target: '2',
type: 'smoothstep',
animated: true,
label: 'Edge Label',
style: { stroke: '#fff', strokeWidth: 2 },
};
```
Built-in edge types:
- `default`: Bezier curve
- `straight`: Straight line
- `step`: Orthogonal with sharp corners
- `smoothstep`: Orthogonal with rounded corners
### Handles
Handles are connection points on nodes. Use `Position` enum for placement:
```typescript
import { Handle, Position } from '@xyflow/react';
<Handle type="target" position={Position.Top} />
<Handle type="source" position={Position.Bottom} />
```
Available positions: `Position.Top`, `Position.Right`, `Position.Bottom`, `Position.Left`
## State Management
### Controlled Flow
Use state hooks for full control:
```typescript
import { useNodesState, useEdgesState, addEdge, OnConnect } from '@xyflow/react';
import { useCallback } from 'react';
function ControlledFlow() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect: OnConnect = useCallback(
(connection) => setEdges((eds) => addEdge(connection, eds)),
[setEdges]
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
/>
);
}
```
### useReactFlow Hook
Access the React Flow instance for programmatic control:
```typescript
import { useReactFlow } from '@xyflow/react';
function FlowControls() {
const {
getNodes,
getEdges,
setNodes,
setEdges,
addNodes,
addEdges,
deleteElements,
fitView,
zoomIn,
zoomOut,
getNode,
getEdge,
updateNode,
updateEdge,
} = useReactFlow();
return (
<button onClick={() => fitView()}>Fit View</button>
);
}
```
## Custom Nodes
Define custom nodes using `NodeProps<T>` with typed data:
```typescript
import { NodeProps, Node, Handle, Position } from '@xyflow/react';
export type CustomNode = Node<{ label: string; status: 'active' | 'inactive' }, 'custom'>;
function CustomNodeComponent({ data, selected }: NodeProps<CustomNode>) {
return (
<div className={`px-4 py-2 ''`}>
<Handle type="target" position={Position.Top} />
<div className="font-bold">{data.label}</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
}
```
Register with `nodeTypes`:
```typescript
const nodeTypes: NodeTypes = { custom: CustomNodeComponent };
<ReactFlow nodeTypes={nodeTypes} />
```
### Key Patterns
- **Multiple Handles**: Use `id` prop and `style` for positioning
- **Dynamic Handles**: Call `useUpdateNodeInternals([nodeId])` after adding/removing handles
- **Interactive Elements**: Add `className="nodrag"` to prevent dragging on inputs/buttons
See [Custom Nodes Reference](./references/custom-nodes.md) for detailed patterns including styling, aviation map pins, and dynamic handles.
## Custom Edges
Define custom edges using `EdgeProps<T>` and path utilities:
```typescript
import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react';
export type CustomEdge = Edge<{ status: 'normal' | 'error' }, 'custom'>;
function CustomEdgeComponent(props: EdgeProps<CustomEdge>) {
const [edgePath] = getBezierPath(props);
return (
<BaseEdge
id={props.id}
path={edgePath}
style={{ stroke: props.data?.status === 'error' ? '#ef4444' : '#64748b' }}
/>
);
}
```
### Path Utilities
- `getBezierPath()` - Smooth curves
- `getStraightPath()` - Straight lines
- `getSmoothStepPath()` - Orthogonal with rounded corners
- `getSmoothStepPath({ borderRadius: 0 })` - Orthogonal with sharp corners (step edge)
All return `[path, labelX, labelY, offsetX, offsetY]`.
### Interactive Labels
Use `EdgeLabelRenderer` for HTML-based labels with pointer events:
```typescript
import { EdgeLabelRenderer, BaseEdge, getBezierPath } from '@xyflow/react';
function ButtonEdge(props: EdgeProps) {
const [edgePath, labelX, labelY] = getBezierPath(props);
return (
<>
<BaseEdge id={props.id} path={edgePath} />
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(labelXpx, labelYpx)`,
pointerEvents: 'all',
}}
className="nodrag nopan"
>
<button onClick={() => console.log('Delete')}>×</button>
</div>
</EdgeLabelRenderer>
</>
);
}
```
See [Custom Edges Reference](./references/custom-edges.md) for animated edges, time labels, and SVG text patterns.
## Viewport Control
Use `useReactFlow()` hook for programmatic viewport control:
```typescript
import { useReactFlow } from '@xyflow/react';
function ViewportControls() {
const { fitView, zoomIn, zoomOut, setCenter, screenToFlowPosition } = useReactFlow();
// Fit all nodes in view
const handleFitView = () => fitView({ padding: 0.2, duration: 400 });
// Zoom controls
const handleZoomIn = () => zoomIn({ duration: 300 });
const handleZoomOut = () => zoomOut({ duration: 300 });
// Center on specific coordinates
const handleCenter = () => setCenter(250, 250, { zoom: 1.5, duration: 500 });
// Convert screen coordinates to flow coordinates
const addNodeAtClick = (event: React.MouseEvent) => {
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
// Use position to add node
};
return null;
}
```
See [Viewport Reference](./references/viewport.md) for save/restore state, controlled viewport, and coordinate transformations.
## Events
React Flow provides comprehensive event handling:
### Node Events
```typescript
import { NodeMouseHandler, OnNodeDrag } from '@xyflow/react';
const onNodeClick: NodeMouseHandler = (event, node) => {
console.log('Node clicked:', node.id);
};
const onNodeDrag: OnNodeDrag = (event, node, nodes) => {
console.log('Dragging:', node.id);
};
<ReactFlow
onNodeClick={onNodeClick}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeClick}
/>
```
### Edge and Connection Events
```typescript
import { EdgeMouseHandler, OnConnect } from '@xyflow/react';
const onEdgeClick: EdgeMouseHandler = (event, edge) => console.log('Edge:', edge.id);
const onConnect: OnConnect = (connection) => console.log('Connected:', connection);
<ReactFlow onEdgeClick={onEdgeClick} onConnect={onConnect} />
```
### Selection and Viewport Events
```typescript
import { useOnSelectionChange, useOnViewportChange } from '@xyflow/react';
useOnSelectionChange({
onChange: ({ nodes, edges }) => console.log('Selected:', nodes.length, edges.length),
});
useOnViewportChange({
onChange: (viewport) => console.log('Viewport:', viewport.zoom),
});
```
See [Events Reference](./references/events.md) for complete event catalog including validation, deletion, and error handling.
## Common Patterns
### Preventing Drag/Pan
```typescript
<input className="nodrag" />
<button className="nodrag nopan">Click me</button>
```
### Connection Validation
```typescript
const isValidConnection = (connection: Connection) => {
return connection.source !== connection.target; // Prevent self-connections
};
<ReactFlow isValidConnection={isValidConnection} />
```
### Adding Nodes on Click
```typescript
const { screenToFlowPosition, setNodes } = useReactFlow();
const onPaneClick = (event: React.MouseEvent) => {
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
setNodes(nodes => [...nodes, { id: `node-Date.now()`, position, data: { label: 'New' } }]);
};
```
### Updating Node Data
```typescript
const { updateNodeData } = useReactFlow();
updateNodeData('node-1', { label: 'Updated' });
updateNodeData('node-1', (node) => ({ ...node.data, count: node.data.count + 1 }));
```
## Provider Pattern
Wrap the app with `ReactFlowProvider` when using `useReactFlow()` outside the flow:
```typescript
import { ReactFlow, ReactFlowProvider, useReactFlow } from '@xyflow/react';
function Controls() {
const { fitView } = useReactFlow(); // Must be inside provider
return <button onClick={() => fitView()}>Fit View</button>;
}
function App() {
return (
<ReactFlowProvider>
<Controls />
<ReactFlow nodes={nodes} edges={edges} />
</ReactFlowProvider>
);
}
```
## Implementation gates
Use these **sequenced checks** before treating an integration as done (they target common footguns, not style preferences).
1. **CSS in the bundle** — Ensure `import '@xyflow/react/dist/style.css'` runs in the app (entry or layout). **Pass:** nodes and edges have expected default styling; handles are visible and interactable.
2. **Stable `nodeTypes` / `edgeTypes`** — Do not pass a fresh object literal every render; define maps outside the component or memoize with `useMemo` and correct deps. **Pass:** no remount flicker or “maximum update depth” / runaway updates when only selection or viewport changes.
3. **Provider boundary** — Components that call `useReactFlow()` must be descendants of `ReactFlowProvider`, and the flow must actually mount. **Pass:** no missing-context error at runtime; programmatic APIs (`fitView`, etc.) work where expected.
## Reference Files
For detailed implementation patterns, see:
- [Custom Nodes](./references/custom-nodes.md) - NodeProps typing, Handle component, dynamic handles, styling patterns
- [Custom Edges](./references/custom-edges.md) - EdgeProps typing, path utilities, EdgeLabelRenderer, animated edges
- [Viewport](./references/viewport.md) - useReactFlow methods, fitView options, coordinate conversion
- [Events](./references/events.md) - Node/edge/connection events, selection handling, viewport changes
FILE:references/custom-edges.md
# Custom Edges
Custom edges in React Flow use the `EdgeProps<T>` typing pattern and path utility functions to render connections between nodes.
## Table of Contents
- [Edge Type Definition](#edge-type-definition)
- [EdgeProps Structure](#edgeprops-structure)
- [Path Utility Functions](#path-utility-functions)
- [BaseEdge Component](#baseedge-component)
- [EdgeLabelRenderer for Interactive Labels](#edgelabelrenderer-for-interactive-labels)
- [Animated Edges](#animated-edges)
- [SVG Text Labels](#svg-text-labels)
- [EdgeText Component](#edgetext-component)
- [Time Label Edge Example](#time-label-edge-example)
- [Edge Registration](#edge-registration)
- [Default Edge Options](#default-edge-options)
## Edge Type Definition
Define custom edge types with typed data:
```typescript
import { Edge, EdgeProps } from '@xyflow/react';
// Define the custom edge type
export type TimeLabelEdge = Edge<{ time: string; label: string }, 'timeLabel'>;
// Component receives EdgeProps
export default function TimeLabelEdge(props: EdgeProps<TimeLabelEdge>) {
// Edge implementation
}
```
## EdgeProps Structure
The `EdgeProps` type includes these key properties:
```typescript
type EdgeProps<T extends Edge = Edge> = {
id: string;
type?: string;
source: string;
target: string;
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
sourcePosition: Position;
targetPosition: Position;
data?: T['data'];
selected?: boolean;
animated?: boolean;
style?: CSSProperties;
markerStart?: string;
markerEnd?: string;
sourceHandleId?: string | null;
targetHandleId?: string | null;
label?: ReactNode;
labelStyle?: CSSProperties;
labelShowBg?: boolean;
labelBgStyle?: CSSProperties;
labelBgPadding?: [number, number];
labelBgBorderRadius?: number;
interactionWidth?: number;
pathOptions?: any;
};
```
## Path Utility Functions
React Flow provides several path generators:
### getBezierPath
Creates smooth curved paths:
```typescript
import { FC } from 'react';
import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react';
const CustomEdge: FC<EdgeProps> = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
}) => {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: 0.25, // Optional: control curve amount (default 0.25)
});
return <BaseEdge path={edgePath} id={id} />;
};
```
### getStraightPath
Creates direct straight lines:
```typescript
import { getStraightPath } from '@xyflow/react';
const [edgePath, labelX, labelY] = getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
```
### getSmoothStepPath
Creates orthogonal paths with smooth corners:
```typescript
import { getSmoothStepPath } from '@xyflow/react';
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 8, // Optional: corner radius
offset: 20, // Optional: offset from node
});
```
### getSmoothStepPath with borderRadius: 0 (Step Edge)
For orthogonal paths with sharp corners, use `getSmoothStepPath` with `borderRadius: 0`:
```typescript
import { getSmoothStepPath } from '@xyflow/react';
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 0, // Sharp corners (step edge)
offset: 20, // Optional: offset from node
});
```
## BaseEdge Component
The `BaseEdge` component renders the path with proper styling:
```typescript
import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react';
function CustomEdge(props: EdgeProps) {
const [edgePath] = getBezierPath(props);
return (
<BaseEdge
id={props.id}
path={edgePath}
style={props.style}
markerEnd={props.markerEnd}
markerStart={props.markerStart}
interactionWidth={20} // Wider click target
/>
);
}
```
## EdgeLabelRenderer for Interactive Labels
Use `EdgeLabelRenderer` to render interactive HTML labels instead of SVG text:
```typescript
import { getBezierPath, EdgeLabelRenderer, BaseEdge, EdgeProps } from '@xyflow/react';
function CustomEdge({ id, data, ...props }: EdgeProps) {
const [edgePath, labelX, labelY] = getBezierPath(props);
return (
<>
<BaseEdge id={id} path={edgePath} />
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(labelXpx, labelYpx)`,
background: '#ffcc00',
padding: 10,
borderRadius: 5,
fontSize: 12,
fontWeight: 700,
pointerEvents: 'all', // Enable interactions
}}
className="nodrag nopan"
>
<button onClick={() => console.log('clicked edge', id)}>
{data?.label || 'Delete'}
</button>
</div>
</EdgeLabelRenderer>
</>
);
}
```
## Animated Edges
### Dash Animation
Animate the stroke dash pattern:
```typescript
const animatedEdgeStyle = {
strokeDasharray: '5 5',
animation: 'dashdraw 0.5s linear infinite',
};
// CSS
// @keyframes dashdraw {
// to {
// stroke-dashoffset: -10;
// }
// }
function AnimatedEdge(props: EdgeProps) {
const [edgePath] = getBezierPath(props);
return <BaseEdge path={edgePath} style={animatedEdgeStyle} />;
}
```
### Moving Circle Along Path
```typescript
import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react';
function MovingCircleEdge(props: EdgeProps) {
const [edgePath] = getBezierPath(props);
return (
<>
<BaseEdge id={props.id} path={edgePath} />
<circle r="4" fill="#ff0072">
<animateMotion dur="2s" repeatCount="indefinite" path={edgePath} />
</circle>
</>
);
}
```
## SVG Text Labels
For simple text labels along the path:
```typescript
import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react';
function TextLabelEdge({ id, data, ...props }: EdgeProps) {
const [edgePath] = getBezierPath(props);
return (
<>
<BaseEdge path={edgePath} id={id} />
<text>
<textPath
href={`#id`}
style={{ fontSize: '12px' }}
startOffset="50%"
textAnchor="middle"
>
{data?.text || ''}
</textPath>
</text>
</>
);
}
```
## EdgeText Component
For positioned text with background:
```typescript
import { BaseEdge, EdgeText, EdgeProps, getSmoothStepPath } from '@xyflow/react';
function LabeledEdge({ id, data, ...props }: EdgeProps) {
const [edgePath, labelX, labelY] = getSmoothStepPath(props);
return (
<>
<BaseEdge id={id} path={edgePath} />
<EdgeText
x={labelX}
y={labelY - 5}
label={data?.text || ''}
labelBgStyle={{ fill: 'white' }}
labelStyle={{ fill: 'black' }}
onClick={() => console.log(data)}
/>
</>
);
}
```
## Time Label Edge Example
Custom edge displaying time/duration labels:
```typescript
import { EdgeProps, getBezierPath, EdgeLabelRenderer, BaseEdge } from '@xyflow/react';
type TimeLabelData = {
duration: string;
status: 'normal' | 'delayed' | 'critical';
};
export type TimeLabelEdge = Edge<TimeLabelData, 'timeLabel'>;
function TimeLabelEdge({ id, data, selected, ...props }: EdgeProps<TimeLabelEdge>) {
const [edgePath, labelX, labelY] = getBezierPath(props);
const statusColors = {
normal: 'bg-green-100 text-green-800',
delayed: 'bg-yellow-100 text-yellow-800',
critical: 'bg-red-100 text-red-800',
};
return (
<>
<BaseEdge
id={id}
path={edgePath}
style={{
strokeWidth: selected ? 2 : 1,
stroke: data?.status === 'critical' ? '#ef4444' : undefined,
}}
/>
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(labelXpx, labelYpx)`,
pointerEvents: 'all',
}}
className="nodrag nopan"
>
<div className={`px-2 py-1 rounded text-xs font-medium statusColors[data?.status || 'normal']`}>
{data?.duration || '0m'}
</div>
</div>
</EdgeLabelRenderer>
</>
);
}
```
## Edge Registration
Register custom edges in the `edgeTypes` prop:
```typescript
import { ReactFlow, EdgeTypes } from '@xyflow/react';
import TimeLabelEdge from './TimeLabelEdge';
import AnimatedEdge from './AnimatedEdge';
const edgeTypes: EdgeTypes = {
timeLabel: TimeLabelEdge,
animated: AnimatedEdge,
};
function Flow() {
return (
<ReactFlow
nodes={nodes}
edges={edges}
edgeTypes={edgeTypes}
/>
);
}
```
## Default Edge Options
Set default properties for all edges:
```typescript
import { DefaultEdgeOptions } from '@xyflow/react';
const defaultEdgeOptions: DefaultEdgeOptions = {
animated: true,
type: 'smoothstep',
style: { stroke: '#fff', strokeWidth: 2 },
};
<ReactFlow defaultEdgeOptions={defaultEdgeOptions} />
```
FILE:references/custom-nodes.md
# Custom Nodes
React Flow custom nodes use the `NodeProps<T>` typing pattern where `T` is the specific node type with custom data.
## Table of Contents
- [Node Type Definition](#node-type-definition)
- [Handle Component](#handle-component)
- [Multiple Handles](#multiple-handles)
- [Dynamic Handles with useUpdateNodeInternals](#dynamic-handles-with-useupdatenodeinternals)
- [Styling Nodes](#styling-nodes)
- [Aviation Map Pin Node Example](#aviation-map-pin-node-example)
- [Preventing Drag and Pan](#preventing-drag-and-pan)
- [Node Registration](#node-registration)
## Node Type Definition
Define custom nodes with typed data and specify the node type string:
```typescript
import { Node, NodeProps } from '@xyflow/react';
// Define the custom node type
export type CounterNode = Node<{ initialCount?: number }, 'counter'>;
// Component receives NodeProps<CounterNode>
export default function CounterNode(props: NodeProps<CounterNode>) {
const [count, setCount] = useState(props.data?.initialCount ?? 0);
return (
<div>
<p>Count: {count}</p>
<button className="nodrag" onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
```
## Handle Component
The `Handle` component defines connection points on nodes. Use `type="target"` for incoming connections and `type="source"` for outgoing connections.
```typescript
import { Handle, Position } from '@xyflow/react';
function CustomNode({ data }) {
return (
<>
<Handle type="target" position={Position.Left} />
<div>{data.label}</div>
<Handle type="source" position={Position.Right} />
</>
);
}
```
### Multiple Handles
Use the `id` prop to create multiple handles on a single node:
```typescript
import { Handle, Position, CSSProperties } from '@xyflow/react';
const sourceHandleStyleA: CSSProperties = { top: 10 };
const sourceHandleStyleB: CSSProperties = { bottom: 10, top: 'auto' };
function MultiHandleNode({ data, isConnectable }: NodeProps<ColorSelectorNode>) {
return (
<>
<Handle type="target" position={Position.Left} />
<div>{data.label}</div>
{/* Multiple source handles with IDs */}
<Handle
type="source"
position={Position.Right}
id="a"
style={sourceHandleStyleA}
isConnectable={isConnectable}
/>
<Handle
type="source"
position={Position.Right}
id="b"
style={sourceHandleStyleB}
isConnectable={isConnectable}
/>
</>
);
}
```
## Dynamic Handles with useUpdateNodeInternals
When adding or removing handles dynamically, use `useUpdateNodeInternals()` to notify React Flow:
```typescript
import { useState, useMemo } from 'react';
import { Handle, Position, useUpdateNodeInternals, NodeProps } from '@xyflow/react';
function DynamicHandleNode({ id }: NodeProps) {
const [handleCount, setHandleCount] = useState(1);
const updateNodeInternals = useUpdateNodeInternals();
const handles = useMemo(
() =>
Array.from({ length: handleCount }, (x, i) => {
const handleId = `handle-i`;
return (
<Handle
key={handleId}
type="source"
position={Position.Right}
id={handleId}
style={{ top: 10 * i }}
/>
);
}),
[handleCount]
);
return (
<div>
<Handle type="target" position={Position.Left} />
<div>output handle count: {handleCount}</div>
<button
onClick={() => {
setHandleCount((c) => c + 1);
updateNodeInternals(id); // Critical: notify React Flow
}}
>
add handle
</button>
{handles}
</div>
);
}
```
## Styling Nodes
### CSS Classes
Apply styles with `className` and `style` props on the node definition:
```typescript
const nodes: Node[] = [
{
id: '1',
type: 'custom',
data: { label: 'Styled Node' },
position: { x: 250, y: 5 },
style: { border: '1px solid #777', padding: 10 },
className: 'custom-node',
},
];
```
### Inline Styles in Component
```typescript
import { CSSProperties } from 'react';
const nodeStyles: CSSProperties = { padding: 10, border: '1px solid #ddd' };
function StyledNode({ data }: NodeProps) {
return (
<div style={nodeStyles}>
{data.label}
</div>
);
}
```
### Tailwind CSS
React Flow works seamlessly with Tailwind:
```typescript
function TailwindNode({ data }: NodeProps) {
return (
<div className="px-4 py-2 shadow-md rounded-md bg-white border-2 border-stone-400">
<div className="flex">
<div className="ml-2">
<div className="text-lg font-bold">{data.name}</div>
<div className="text-gray-500">{data.job}</div>
</div>
</div>
<Handle type="target" position={Position.Top} className="w-16 !bg-teal-500" />
<Handle type="source" position={Position.Bottom} className="w-16 !bg-teal-500" />
</div>
);
}
```
## Aviation Map Pin Node Example
Custom node with status-based styling using data-driven approach:
```typescript
import { NodeProps, Handle, Position } from '@xyflow/react';
type MapPinData = {
label: string;
status: 'active' | 'warning' | 'inactive';
coordinate: { lat: number; lon: number };
};
export type MapPinNode = Node<MapPinData, 'mapPin'>;
function MapPinNode({ data, selected }: NodeProps<MapPinNode>) {
const statusColors = {
active: 'bg-green-500',
warning: 'bg-yellow-500',
inactive: 'bg-gray-400',
};
return (
<div className={`relative ''`}>
{/* Beacon glow for active status */}
{data.status === 'active' && (
<div className="absolute inset-0 animate-ping bg-green-500 rounded-full opacity-75" />
)}
{/* Pin icon */}
<div className={`relative w-8 h-8 rounded-full statusColors[data.status]`}>
<div className="absolute inset-0 flex items-center justify-center text-white font-bold">
{data.label}
</div>
</div>
{/* Connection handle at bottom */}
<Handle type="source" position={Position.Bottom} className="opacity-0" />
</div>
);
}
```
## Preventing Drag and Pan
Use `nodrag` and `nopan` classes to prevent interactions on specific elements:
```typescript
function InteractiveNode({ data }: NodeProps) {
return (
<div>
<input
className="nodrag"
type="text"
defaultValue={data.label}
/>
<button className="nodrag nopan" onClick={() => console.log('clicked')}>
Click me
</button>
</div>
);
}
```
## Node Registration
Register custom nodes in the `nodeTypes` prop:
```typescript
import { ReactFlow, NodeTypes } from '@xyflow/react';
import CustomNode from './CustomNode';
import MapPinNode from './MapPinNode';
const nodeTypes: NodeTypes = {
custom: CustomNode,
mapPin: MapPinNode,
};
function Flow() {
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
/>
);
}
```
FILE:references/events.md
# Events
React Flow provides comprehensive event handling for nodes, edges, connections, selections, and viewport changes.
## Table of Contents
- [Node Events](#node-events)
- [Click Events](#click-events)
- [Drag Events](#drag-events)
- [Hover Events](#hover-events)
- [Edge Events](#edge-events)
- [Click Events](#click-events-1)
- [Hover Events](#hover-events-1)
- [Edge Update and Reconnect](#edge-update-and-reconnect)
- [Connection Events](#connection-events)
- [Basic Connection](#basic-connection)
- [Connection Start and End](#connection-start-and-end)
- [Validate Connections](#validate-connections)
- [Selection Events](#selection-events)
- [useOnSelectionChange Hook](#useonselectionchange-hook)
- [Selection Drag](#selection-drag)
- [Selection Context Menu](#selection-context-menu)
- [Viewport Events](#viewport-events)
- [useOnViewportChange Hook](#useonviewportchange-hook)
- [Move Events](#move-events)
- [Pane Events](#pane-events)
- [Click Events](#click-events-2)
- [Mouse Events](#mouse-events)
- [Init and Delete Events](#init-and-delete-events)
- [Initialization](#initialization)
- [Delete Events](#delete-events)
- [Error Handling](#error-handling)
## Node Events
### Click Events
```typescript
import { ReactFlow, NodeMouseHandler, Node } from '@xyflow/react';
function NodeClickExample() {
const onNodeClick: NodeMouseHandler = (event, node) => {
console.log('Node clicked:', node.id, node.data);
};
const onNodeDoubleClick: NodeMouseHandler = (event, node) => {
console.log('Node double-clicked:', node.id);
};
const onNodeContextMenu: NodeMouseHandler = (event, node) => {
event.preventDefault();
console.log('Node right-clicked:', node.id);
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeContextMenu={onNodeContextMenu}
/>
);
}
```
### Drag Events
```typescript
import { ReactFlow, OnNodeDrag, NodeMouseHandler } from '@xyflow/react';
function NodeDragExample() {
const onNodeDragStart: NodeMouseHandler = (event, node) => {
console.log('Drag started:', node.id);
};
const onNodeDrag: OnNodeDrag = (event, node, nodes) => {
console.log('Dragging:', node.id, 'at', node.position);
console.log('All dragged nodes:', nodes.map(n => n.id));
};
const onNodeDragStop: NodeMouseHandler = (event, node) => {
console.log('Drag stopped:', node.id, 'at', node.position);
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
/>
);
}
```
### Hover Events
```typescript
import { ReactFlow, NodeMouseHandler } from '@xyflow/react';
function NodeHoverExample() {
const onNodeMouseEnter: NodeMouseHandler = (event, node) => {
console.log('Mouse entered:', node.id);
};
const onNodeMouseMove: NodeMouseHandler = (event, node) => {
console.log('Mouse moving over:', node.id);
};
const onNodeMouseLeave: NodeMouseHandler = (event, node) => {
console.log('Mouse left:', node.id);
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseMove={onNodeMouseMove}
onNodeMouseLeave={onNodeMouseLeave}
/>
);
}
```
## Edge Events
### Click Events
```typescript
import { ReactFlow, EdgeMouseHandler } from '@xyflow/react';
function EdgeClickExample() {
const onEdgeClick: EdgeMouseHandler = (event, edge) => {
console.log('Edge clicked:', edge.id);
console.log('From:', edge.source, 'To:', edge.target);
};
const onEdgeDoubleClick: EdgeMouseHandler = (event, edge) => {
console.log('Edge double-clicked:', edge.id);
};
const onEdgeContextMenu: EdgeMouseHandler = (event, edge) => {
event.preventDefault();
console.log('Edge right-clicked:', edge.id);
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onEdgeClick={onEdgeClick}
onEdgeDoubleClick={onEdgeDoubleClick}
onEdgeContextMenu={onEdgeContextMenu}
/>
);
}
```
### Hover Events
```typescript
import { ReactFlow, EdgeMouseHandler } from '@xyflow/react';
function EdgeHoverExample() {
const onEdgeMouseEnter: EdgeMouseHandler = (event, edge) => {
console.log('Mouse entered edge:', edge.id);
};
const onEdgeMouseMove: EdgeMouseHandler = (event, edge) => {
console.log('Mouse moving over edge:', edge.id);
};
const onEdgeMouseLeave: EdgeMouseHandler = (event, edge) => {
console.log('Mouse left edge:', edge.id);
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseMove={onEdgeMouseMove}
onEdgeMouseLeave={onEdgeMouseLeave}
/>
);
}
```
### Edge Update and Reconnect
```typescript
import { ReactFlow, OnReconnect, OnReconnectStart, OnReconnectEnd } from '@xyflow/react';
function EdgeReconnectExample() {
const onReconnect: OnReconnect = (oldEdge, newConnection) => {
console.log('Edge reconnected:', oldEdge.id);
console.log('New connection:', newConnection);
};
const onReconnectStart: OnReconnectStart = (event, edge, handleType) => {
console.log('Reconnect started:', edge.id, 'handle:', handleType);
};
const onReconnectEnd: OnReconnectEnd = (event, edge, handleType, connectionState) => {
console.log('Reconnect ended:', edge.id);
console.log('Connection state:', connectionState);
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
edgesReconnectable={true}
/>
);
}
```
## Connection Events
### Basic Connection
```typescript
import { ReactFlow, OnConnect, addEdge } from '@xyflow/react';
import { useCallback } from 'react';
function ConnectionExample() {
const [edges, setEdges] = useState<Edge[]>([]);
const onConnect: OnConnect = useCallback(
(connection) => {
console.log('Connection made:', connection);
console.log('Source:', connection.source);
console.log('Target:', connection.target);
console.log('Source Handle:', connection.sourceHandle);
console.log('Target Handle:', connection.targetHandle);
setEdges((eds) => addEdge(connection, eds));
},
[setEdges]
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onConnect={onConnect}
/>
);
}
```
### Connection Start and End
```typescript
import { ReactFlow, OnConnectStart, OnConnectEnd } from '@xyflow/react';
function ConnectionLifecycleExample() {
const onConnectStart: OnConnectStart = (event, { nodeId, handleId, handleType }) => {
console.log('Connection started from:', nodeId);
console.log('Handle:', handleId, 'Type:', handleType);
};
const onConnectEnd: OnConnectEnd = (event, connectionState) => {
console.log('Connection ended');
console.log('Was valid:', connectionState.isValid);
console.log('From node:', connectionState.fromNode?.id);
console.log('To node:', connectionState.toNode?.id);
console.log('From handle:', connectionState.fromHandle);
console.log('To handle:', connectionState.toHandle);
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
/>
);
}
```
### Validate Connections
```typescript
import { ReactFlow, Connection, Edge, Node } from '@xyflow/react';
function ValidatedConnectionExample() {
const isValidConnection = (connection: Connection | Edge) => {
// Prevent self-connections
if (connection.source === connection.target) {
return false;
}
// Custom validation logic
const sourceNode = nodes.find(n => n.id === connection.source);
const targetNode = nodes.find(n => n.id === connection.target);
// Prevent connections from output nodes
if (sourceNode?.type === 'output') {
return false;
}
// Prevent connections to input nodes
if (targetNode?.type === 'input') {
return false;
}
return true;
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
isValidConnection={isValidConnection}
/>
);
}
```
## Selection Events
### useOnSelectionChange Hook
```typescript
import { useOnSelectionChange, OnSelectionChangeParams } from '@xyflow/react';
import { useCallback } from 'react';
function SelectionLogger() {
const onChange = useCallback(({ nodes, edges }: OnSelectionChangeParams) => {
console.log('Selected nodes:', nodes.map(n => n.id));
console.log('Selected edges:', edges.map(e => e.id));
}, []);
useOnSelectionChange({
onChange,
});
return null;
}
function SelectionExample() {
return (
<ReactFlow nodes={nodes} edges={edges}>
<SelectionLogger />
</ReactFlow>
);
}
```
### Selection Drag
```typescript
import { ReactFlow, SelectionDragHandler } from '@xyflow/react';
function SelectionDragExample() {
const onSelectionDragStart: SelectionDragHandler = (event, nodes) => {
console.log('Selection drag started:', nodes.length, 'nodes');
};
const onSelectionDrag: SelectionDragHandler = (event, nodes) => {
console.log('Dragging selection:', nodes.map(n => n.id));
};
const onSelectionDragStop: SelectionDragHandler = (event, nodes) => {
console.log('Selection drag stopped');
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onSelectionDragStart={onSelectionDragStart}
onSelectionDrag={onSelectionDrag}
onSelectionDragStop={onSelectionDragStop}
/>
);
}
```
### Selection Context Menu
```typescript
import { ReactFlow, Node, Edge } from '@xyflow/react';
function SelectionContextMenuExample() {
const onSelectionContextMenu = (event: React.MouseEvent, nodes: Node[]) => {
event.preventDefault();
console.log('Context menu on selection:', nodes.map(n => n.id));
// Show custom context menu
// ... context menu logic
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onSelectionContextMenu={onSelectionContextMenu}
/>
);
}
```
## Viewport Events
### useOnViewportChange Hook
```typescript
import { useOnViewportChange, Viewport } from '@xyflow/react';
import { useCallback } from 'react';
function ViewportLogger() {
const onStart = useCallback((viewport: Viewport) => {
console.log('Viewport change started:', viewport);
}, []);
const onChange = useCallback((viewport: Viewport) => {
console.log('Viewport:', {
x: viewport.x,
y: viewport.y,
zoom: viewport.zoom,
});
}, []);
const onEnd = useCallback((viewport: Viewport) => {
console.log('Viewport change ended:', viewport);
}, []);
useOnViewportChange({
onStart,
onChange,
onEnd,
});
return null;
}
```
### Move Events
```typescript
import { ReactFlow, OnMove } from '@xyflow/react';
function MoveExample() {
const onMove: OnMove = (event, viewport) => {
console.log('Viewport moved to:', viewport);
};
const onMoveStart: OnMove = (event, viewport) => {
console.log('Move started from:', viewport);
};
const onMoveEnd: OnMove = (event, viewport) => {
console.log('Move ended at:', viewport);
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onMove={onMove}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
/>
);
}
```
## Pane Events
### Click Events
```typescript
import { ReactFlow } from '@xyflow/react';
import { MouseEvent } from 'react';
function PaneClickExample() {
const onPaneClick = (event: MouseEvent) => {
console.log('Pane clicked at:', event.clientX, event.clientY);
};
const onPaneContextMenu = (event: MouseEvent) => {
event.preventDefault();
console.log('Pane right-clicked');
};
const onPaneScroll = (event?: MouseEvent | WheelEvent) => {
console.log('Pane scrolled');
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onPaneClick={onPaneClick}
onPaneContextMenu={onPaneContextMenu}
onPaneScroll={onPaneScroll}
/>
);
}
```
### Mouse Events
```typescript
import { ReactFlow } from '@xyflow/react';
import { MouseEvent } from 'react';
function PaneMouseExample() {
const onPaneMouseEnter = (event: MouseEvent) => {
console.log('Mouse entered pane');
};
const onPaneMouseMove = (event: MouseEvent) => {
console.log('Mouse moving over pane');
};
const onPaneMouseLeave = (event: MouseEvent) => {
console.log('Mouse left pane');
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onPaneMouseEnter={onPaneMouseEnter}
onPaneMouseMove={onPaneMouseMove}
onPaneMouseLeave={onPaneMouseLeave}
/>
);
}
```
## Init and Delete Events
### Initialization
```typescript
import { ReactFlow, OnInit, ReactFlowInstance } from '@xyflow/react';
function InitExample() {
const onInit: OnInit = (reactFlowInstance: ReactFlowInstance) => {
console.log('React Flow initialized');
console.log('Viewport:', reactFlowInstance.getViewport());
reactFlowInstance.fitView();
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onInit={onInit}
/>
);
}
```
### Delete Events
```typescript
import { ReactFlow, OnNodesDelete, OnEdgesDelete, OnBeforeDelete } from '@xyflow/react';
function DeleteExample() {
const onNodesDelete: OnNodesDelete = (nodes) => {
console.log('Nodes deleted:', nodes.map(n => n.id));
};
const onEdgesDelete: OnEdgesDelete = (edges) => {
console.log('Edges deleted:', edges.map(e => e.id));
};
const onBeforeDelete: OnBeforeDelete = async ({ nodes, edges }) => {
console.log('About to delete:', nodes.length, 'nodes and', edges.length, 'edges');
// Return true to allow deletion, false to cancel
const confirmed = window.confirm('Delete selected elements?');
return confirmed;
};
const onDelete = ({ nodes, edges }) => {
console.log('Deleted:', nodes.length, 'nodes and', edges.length, 'edges');
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onBeforeDelete={onBeforeDelete}
onDelete={onDelete}
/>
);
}
```
## Error Handling
```typescript
import { ReactFlow, OnError } from '@xyflow/react';
function ErrorHandlingExample() {
const onError: OnError = (code, message) => {
console.error(`React Flow Error [code]:`, message);
// Handle specific error codes
if (code === '010') {
console.error('Handle must be rendered inside a custom node');
}
};
return (
<ReactFlow
nodes={nodes}
edges={edges}
onError={onError}
/>
);
}
```
FILE:references/viewport.md
# Viewport Control
React Flow provides viewport control through the `useReactFlow()` hook, which exposes methods for programmatic navigation, zoom, and coordinate transformations.
## Table of Contents
- [useReactFlow Hook](#usereactflow-hook)
- [fitView Method](#fitview-method)
- [Zoom Methods](#zoom-methods)
- [setViewport Method](#setviewport-method)
- [setCenter Method](#setcenter-method)
- [screenToFlowPosition Method](#screentoflowposition-method)
- [flowToScreenPosition Method](#flowtoscreenposition-method)
- [Save and Restore Viewport State](#save-and-restore-viewport-state)
- [Programmatic Pan to Node](#programmatic-pan-to-node)
- [Controlled Viewport](#controlled-viewport)
- [useOnViewportChange Hook](#useonviewportchange-hook)
- [getNodesBounds Method](#getnodesbounds-method)
- [viewportInitialized Flag](#viewportinitialized-flag)
## useReactFlow Hook
The main hook for accessing viewport and flow instance methods:
```typescript
import { useReactFlow } from '@xyflow/react';
function ViewportControls() {
const reactFlow = useReactFlow();
// Access viewport methods
const handleZoomIn = () => reactFlow.zoomIn();
const handleFitView = () => reactFlow.fitView();
return (
<div>
<button onClick={handleZoomIn}>Zoom In</button>
<button onClick={handleFitView}>Fit View</button>
</div>
);
}
```
## fitView Method
Adjusts the viewport to fit all nodes in view:
```typescript
import { useReactFlow, FitViewOptions } from '@xyflow/react';
function FitViewExample() {
const { fitView } = useReactFlow();
const handleFitView = async () => {
// Basic usage
await fitView();
// With options
await fitView({
padding: 0.2, // 20% padding around nodes
includeHiddenNodes: false, // Don't include hidden nodes
minZoom: 0.5, // Minimum zoom level
maxZoom: 2, // Maximum zoom level
duration: 200, // Animation duration in ms
});
};
return <button onClick={handleFitView}>Fit View</button>;
}
```
### fitView with Specific Nodes
Fit viewport to a subset of nodes:
```typescript
import { useReactFlow } from '@xyflow/react';
function FitSpecificNodes() {
const { fitView, getNodes } = useReactFlow();
const fitSelectedNodes = async () => {
const selectedNodes = getNodes().filter(node => node.selected);
if (selectedNodes.length > 0) {
await fitView({
nodes: selectedNodes,
padding: 0.3,
duration: 400,
});
}
};
return <button onClick={fitSelectedNodes}>Fit Selected</button>;
}
```
## Zoom Methods
```typescript
import { useReactFlow } from '@xyflow/react';
function ZoomControls() {
const { zoomIn, zoomOut, zoomTo, getZoom } = useReactFlow();
const handleZoomIn = () => {
zoomIn({ duration: 300 }); // Animated zoom
};
const handleZoomOut = () => {
zoomOut({ duration: 300 });
};
const handleZoomTo = () => {
zoomTo(1.5, { duration: 500 }); // Zoom to specific level
};
const handleGetZoom = () => {
const currentZoom = getZoom();
console.log('Current zoom:', currentZoom);
};
return (
<div>
<button onClick={handleZoomIn}>Zoom In</button>
<button onClick={handleZoomOut}>Zoom Out</button>
<button onClick={handleZoomTo}>Zoom to 1.5x</button>
<button onClick={handleGetZoom}>Get Zoom</button>
</div>
);
}
```
## setViewport Method
Directly set the viewport position and zoom:
```typescript
import { useReactFlow, Viewport } from '@xyflow/react';
function ViewportSetter() {
const { setViewport, getViewport } = useReactFlow();
const handleSetViewport = () => {
const newViewport: Viewport = {
x: 100,
y: 100,
zoom: 1.2,
};
setViewport(newViewport, { duration: 400 });
};
const handleGetViewport = () => {
const viewport = getViewport();
console.log('Current viewport:', viewport);
// { x: 0, y: 0, zoom: 1 }
};
return (
<div>
<button onClick={handleSetViewport}>Set Viewport</button>
<button onClick={handleGetViewport}>Get Viewport</button>
</div>
);
}
```
## setCenter Method
Center the viewport on specific coordinates:
```typescript
import { useReactFlow } from '@xyflow/react';
function CenterControls() {
const { setCenter } = useReactFlow();
const centerOnPosition = () => {
setCenter(
250, // x coordinate
250, // y coordinate
{
zoom: 1.5,
duration: 500,
}
);
};
return <button onClick={centerOnPosition}>Center on (250, 250)</button>;
}
```
## screenToFlowPosition Method
Convert screen coordinates to flow coordinates:
```typescript
import { useReactFlow } from '@xyflow/react';
import { MouseEvent } from 'react';
function ClickToAddNode() {
const { screenToFlowPosition, setNodes } = useReactFlow();
const handlePaneClick = (event: MouseEvent) => {
// Convert click position to flow coordinates
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// Add node at click position
setNodes((nodes) => [
...nodes,
{
id: `node-Date.now()`,
position,
data: { label: 'New Node' },
},
]);
};
return <ReactFlow onPaneClick={handlePaneClick} />;
}
```
## flowToScreenPosition Method
Convert flow coordinates to screen coordinates:
```typescript
import { useReactFlow } from '@xyflow/react';
function PositionConverter() {
const { flowToScreenPosition } = useReactFlow();
const getScreenPosition = () => {
const screenPos = flowToScreenPosition({
x: 100,
y: 100,
});
console.log('Screen position:', screenPos);
};
return <button onClick={getScreenPosition}>Get Screen Position</button>;
}
```
## Save and Restore Viewport State
```typescript
import { useState } from 'react';
import { useReactFlow, Viewport } from '@xyflow/react';
function ViewportPersistence() {
const { setViewport, getViewport } = useReactFlow();
const [savedViewport, setSavedViewport] = useState<Viewport | null>(null);
const saveViewport = () => {
const viewport = getViewport();
setSavedViewport(viewport);
// Optionally save to localStorage
localStorage.setItem('flowViewport', JSON.stringify(viewport));
};
const restoreViewport = () => {
if (savedViewport) {
setViewport(savedViewport, { duration: 300 });
} else {
// Load from localStorage
const stored = localStorage.getItem('flowViewport');
if (stored) {
const viewport = JSON.parse(stored) as Viewport;
setViewport(viewport, { duration: 300 });
}
}
};
return (
<div>
<button onClick={saveViewport}>Save Viewport</button>
<button onClick={restoreViewport}>Restore Viewport</button>
</div>
);
}
```
## Programmatic Pan to Node
Pan the viewport to focus on a specific node:
```typescript
import { useReactFlow } from '@xyflow/react';
function PanToNode() {
const { getNode, setCenter } = useReactFlow();
const panToNodeById = (nodeId: string) => {
const node = getNode(nodeId);
if (node) {
const x = node.position.x + (node.width ?? 0) / 2;
const y = node.position.y + (node.height ?? 0) / 2;
setCenter(x, y, { zoom: 1.5, duration: 500 });
}
};
return (
<button onClick={() => panToNodeById('node-1')}>
Pan to Node 1
</button>
);
}
```
## Controlled Viewport
Control viewport directly through state:
```typescript
import { useState, useCallback } from 'react';
import { ReactFlow, Viewport, useReactFlow } from '@xyflow/react';
function ControlledViewportFlow() {
const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, zoom: 1 });
const { fitView } = useReactFlow();
const handleViewportChange = useCallback((newViewport: Viewport) => {
setViewport(newViewport);
}, []);
const updateViewport = () => {
setViewport((vp) => ({ ...vp, y: vp.y + 10 }));
};
return (
<>
<button onClick={updateViewport}>Move Down</button>
<button onClick={() => fitView()}>Fit View</button>
<ReactFlow
nodes={nodes}
edges={edges}
viewport={viewport}
onViewportChange={handleViewportChange}
/>
</>
);
}
```
## useOnViewportChange Hook
Listen to viewport changes:
```typescript
import { useOnViewportChange, Viewport } from '@xyflow/react';
import { useCallback } from 'react';
function ViewportLogger() {
const onStart = useCallback((viewport: Viewport) => {
console.log('Viewport change started:', viewport);
}, []);
const onChange = useCallback((viewport: Viewport) => {
console.log('Viewport changing:', viewport);
}, []);
const onEnd = useCallback((viewport: Viewport) => {
console.log('Viewport change ended:', viewport);
}, []);
useOnViewportChange({
onStart,
onChange,
onEnd,
});
return null;
}
```
## getNodesBounds Method
Get bounding box of specific nodes:
```typescript
import { useReactFlow } from '@xyflow/react';
function NodeBounds() {
const { getNodesBounds, getNodes } = useReactFlow();
const logSelectedBounds = () => {
const selectedNodes = getNodes().filter(n => n.selected);
const bounds = getNodesBounds(selectedNodes);
console.log('Bounds:', {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
});
};
return <button onClick={logSelectedBounds}>Log Selected Bounds</button>;
}
```
## viewportInitialized Flag
Check if viewport is initialized before using methods:
```typescript
import { useReactFlow } from '@xyflow/react';
function SafeViewportControls() {
const { viewportInitialized, fitView } = useReactFlow();
const handleFitView = () => {
if (viewportInitialized) {
fitView();
} else {
console.warn('Viewport not yet initialized');
}
};
return (
<button onClick={handleFitView} disabled={!viewportInitialized}>
Fit View
</button>
);
}
```
Implements React Flow node-based UIs correctly using @xyflow/react. Use when building flow charts, diagrams, visual editors, or node-based applications with...
---
name: react-flow-implementation
description: Implements React Flow node-based UIs correctly using @xyflow/react. Use when building flow charts, diagrams, visual editors, or node-based applications with React. Covers nodes, edges, handles, custom components, state management, and viewport control.
---
# React Flow Implementation
## Quick Start
```tsx
import { useCallback } from 'react';
import { ReactFlow, useNodesState, useEdgesState, addEdge } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
const initialNodes = [
{ id: '1', position: { x: 0, y: 0 }, data: { label: 'Node 1' } },
{ id: '2', position: { x: 200, y: 100 }, data: { label: 'Node 2' } },
];
const initialEdges = [{ id: 'e1-2', source: '1', target: '2' }];
export default function Flow() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback(
(connection) => setEdges((eds) => addEdge(connection, eds)),
[setEdges]
);
return (
<div style={{ width: '100%', height: '100vh' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
/>
</div>
);
}
```
## Implementation gates
Run these in order; do not skip ahead on “looks fine.”
1. **Styles on the page** — **Pass if** the bundle that renders `<ReactFlow />` imports `@xyflow/react/dist/style.css` (or equivalent CSS pipeline) so nodes/edges are visible and hit-targets match visuals.
2. **`useReactFlow` boundary** — **Pass if** every caller of `useReactFlow()` is a descendant of `<ReactFlowProvider>` that wraps the same tree as `<ReactFlow />` (or you have intentionally split stores and can name both roots).
3. **Stable `nodeTypes` / `edgeTypes`** — **Pass if** those maps are **module-scope constants** or `useMemo` with stable deps—not a new `{ ... }` literal each render in the component that renders `<ReactFlow />`.
4. **Handles match edges** — **Pass if** for nodes with multiple `Handle` `id`s, every edge that must land on a specific handle sets `sourceHandle` / `targetHandle` accordingly (or you accept default handle resolution deliberately).
See [ADDITIONAL_COMPONENTS.md](ADDITIONAL_COMPONENTS.md) for MiniMap, Controls, Background, NodeToolbar, and NodeResizer patterns.
## Core Patterns
### TypeScript Types
```typescript
import type { Node, Edge, NodeProps, BuiltInNode } from '@xyflow/react';
// Define custom node type with data shape
type CustomNode = Node<{ value: number; label: string }, 'custom'>;
// Combine with built-in nodes
type MyNode = CustomNode | BuiltInNode;
type MyEdge = Edge<{ weight?: number }>;
// Use throughout app
const [nodes, setNodes] = useNodesState<MyNode>(initialNodes);
```
### Custom Nodes
```tsx
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
// Define node type
type CounterNode = Node<{ count: number }, 'counter'>;
// Always wrap in memo for performance
const CounterNode = memo(function CounterNode({ data, isConnectable }: NodeProps<CounterNode>) {
return (
<>
<Handle type="target" position={Position.Top} isConnectable={isConnectable} />
<div className="counter-node">
Count: {data.count}
{/* nodrag prevents dragging when interacting with button */}
<button className="nodrag" onClick={() => console.log('clicked')}>
Increment
</button>
</div>
<Handle type="source" position={Position.Bottom} isConnectable={isConnectable} />
</>
);
});
// Register in nodeTypes (define OUTSIDE component to avoid re-renders)
const nodeTypes = { counter: CounterNode };
// Use in ReactFlow
<ReactFlow nodeTypes={nodeTypes} ... />
```
### Multiple Handles
```tsx
// Use handle IDs when a node has multiple handles of same type
<Handle type="source" position={Position.Right} id="a" />
<Handle type="source" position={Position.Right} id="b" style={{ top: 20 }} />
// Connect with specific handles
const edge = {
id: 'e1-2',
source: '1',
sourceHandle: 'a',
target: '2',
targetHandle: null
};
```
### Custom Edges
```tsx
import { BaseEdge, EdgeProps, getSmoothStepPath } from '@xyflow/react';
function CustomEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data }: EdgeProps) {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX, sourceY, sourcePosition,
targetX, targetY, targetPosition,
});
return (
<>
<BaseEdge id={id} path={edgePath} />
<text x={labelX} y={labelY} className="edge-label">{data?.label}</text>
</>
);
}
const edgeTypes = { custom: CustomEdge };
```
## State Management
### Controlled (Recommended for Production)
```tsx
// External state with change handlers
const [nodes, setNodes] = useState<Node[]>(initialNodes);
const [edges, setEdges] = useState<Edge[]>(initialEdges);
const onNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[]
);
const onEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[]
);
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
/>
```
### Using useReactFlow
```tsx
import { useReactFlow, ReactFlowProvider } from '@xyflow/react';
function FlowControls() {
const {
getNodes, setNodes, addNodes, updateNodeData,
getEdges, setEdges, addEdges,
fitView, zoomIn, zoomOut, setViewport,
deleteElements, toObject,
} = useReactFlow();
const addNode = () => {
addNodes({ id: `Date.now()`, position: { x: 100, y: 100 }, data: { label: 'New' } });
};
return <button onClick={addNode}>Add Node</button>;
}
// Must wrap in provider when using useReactFlow
function App() {
return (
<ReactFlowProvider>
<Flow />
<FlowControls />
</ReactFlowProvider>
);
}
```
### Updating Node Data
```tsx
const { updateNodeData } = useReactFlow();
// Merge with existing data
updateNodeData(nodeId, { label: 'Updated' });
// Replace data entirely
updateNodeData(nodeId, { newField: 'value' }, { replace: true });
```
## Viewport & Fit View
```tsx
// Fit on initial render
<ReactFlow fitView fitViewOptions={{ padding: 0.2, maxZoom: 1 }} />
// Programmatic control
const { fitView, setViewport, getViewport, zoomTo } = useReactFlow();
// Fit to specific nodes
fitView({ nodes: [{ id: '1' }, { id: '2' }], duration: 500 });
// Set exact viewport
setViewport({ x: 100, y: 100, zoom: 1.5 }, { duration: 300 });
```
## Connection Validation
```tsx
const isValidConnection = useCallback((connection: Connection) => {
// Prevent self-connections
if (connection.source === connection.target) return false;
// Custom validation logic
const sourceNode = getNode(connection.source);
const targetNode = getNode(connection.target);
return sourceNode?.type !== targetNode?.type;
}, []);
<ReactFlow isValidConnection={isValidConnection} />
```
## Common Props Reference
```tsx
<ReactFlow
// Core data
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
// Custom types (define OUTSIDE component)
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
// Connections
onConnect={onConnect}
connectionMode={ConnectionMode.Loose} // Allow target-to-target
isValidConnection={isValidConnection}
// Viewport
fitView
minZoom={0.1}
maxZoom={4}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
// Interaction
nodesDraggable={true}
nodesConnectable={true}
elementsSelectable={true}
panOnDrag={true}
zoomOnScroll={true}
// Additional components
<MiniMap />
<Controls />
<Background variant={BackgroundVariant.Dots} />
</ReactFlow>
```
## CSS Classes for Interaction
| Class | Effect |
|-------|--------|
| `nodrag` | Prevent dragging when clicking element |
| `nowheel` | Prevent zoom on wheel events |
| `nopan` | Prevent panning from element |
| `nokey` | Prevent keyboard events (use on inputs) |
## Additional Components
See [ADDITIONAL_COMPONENTS.md](ADDITIONAL_COMPONENTS.md) for MiniMap, Controls, Background, NodeToolbar, NodeResizer.
FILE:ADDITIONAL_COMPONENTS.md
# React Flow Additional Components
## MiniMap
```tsx
import { MiniMap } from '@xyflow/react';
<MiniMap
nodeColor={(node) => {
switch (node.type) {
case 'input': return '#6ede87';
case 'output': return '#ff0072';
default: return '#eee';
}
}}
nodeStrokeWidth={3}
zoomable
pannable
/>
```
## Controls
```tsx
import { Controls } from '@xyflow/react';
<Controls
showZoom={true}
showFitView={true}
showInteractive={true}
position="bottom-left"
/>
```
## Background
```tsx
import { Background, BackgroundVariant } from '@xyflow/react';
// Dots pattern
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
// Lines pattern
<Background variant={BackgroundVariant.Lines} gap={24} />
// Cross pattern
<Background variant={BackgroundVariant.Cross} />
// Custom color
<Background bgColor="#1a1a1a" color="#444" />
```
## NodeToolbar
```tsx
import { NodeToolbar, Position } from '@xyflow/react';
function CustomNode({ id, selected }: NodeProps) {
return (
<>
<NodeToolbar
isVisible={selected}
position={Position.Top}
>
<button onClick={() => console.log('delete', id)}>Delete</button>
<button>Edit</button>
</NodeToolbar>
<div>Node Content</div>
</>
);
}
```
## NodeResizer
```tsx
import { NodeResizer } from '@xyflow/react';
function ResizableNode({ selected }: NodeProps) {
return (
<>
<NodeResizer
isVisible={selected}
minWidth={100}
minHeight={50}
handleStyle={{ width: 8, height: 8 }}
/>
<div style={{ padding: 10 }}>
Resize me
</div>
</>
);
}
```
## EdgeToolbar (for custom edges)
```tsx
import { EdgeToolbar } from '@xyflow/react';
function CustomEdge({ id, selected, ...props }: EdgeProps) {
const [path, labelX, labelY] = getSmoothStepPath(props);
return (
<>
<BaseEdge path={path} />
<EdgeToolbar x={labelX} y={labelY} isVisible={selected}>
<button>Edit</button>
</EdgeToolbar>
</>
);
}
```
## Panel
```tsx
import { Panel } from '@xyflow/react';
// Positions: top-left, top-center, top-right, bottom-left, bottom-center, bottom-right
<ReactFlow ...>
<Panel position="top-right">
<button onClick={onSave}>Save</button>
<button onClick={onRestore}>Restore</button>
</Panel>
</ReactFlow>
```
FILE:EDGE_PATHS.md
# Edge Path Utilities
React Flow provides utilities for generating SVG paths for custom edges.
## Available Path Functions
### getBezierPath (Default)
```tsx
import { getBezierPath, Position } from '@xyflow/react';
const [path, labelX, labelY, offsetX, offsetY] = getBezierPath({
sourceX: 0,
sourceY: 0,
sourcePosition: Position.Right,
targetX: 200,
targetY: 100,
targetPosition: Position.Left,
curvature: 0.25, // optional, default 0.25
});
```
### getSmoothStepPath
```tsx
import { getSmoothStepPath } from '@xyflow/react';
const [path, labelX, labelY] = getSmoothStepPath({
sourceX, sourceY, sourcePosition,
targetX, targetY, targetPosition,
borderRadius: 5, // Corner rounding
offset: 20, // Distance from handle before first bend
stepPosition: 0.5, // 0-1, where bend occurs (0=source, 1=target)
});
```
### getStraightPath
```tsx
import { getStraightPath } from '@xyflow/react';
const [path, labelX, labelY] = getStraightPath({
sourceX, sourceY,
targetX, targetY,
});
```
### getSimpleBezierPath
```tsx
import { getSimpleBezierPath } from '@xyflow/react';
const [path, labelX, labelY] = getSimpleBezierPath({
sourceX, sourceY, sourcePosition,
targetX, targetY, targetPosition,
});
```
## Custom Edge Example
```tsx
import { BaseEdge, EdgeProps, getSmoothStepPath, EdgeLabelRenderer } from '@xyflow/react';
function CustomEdge({
id, sourceX, sourceY, targetX, targetY,
sourcePosition, targetPosition, data, style
}: EdgeProps) {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX, sourceY, sourcePosition,
targetX, targetY, targetPosition,
});
return (
<>
<BaseEdge id={id} path={edgePath} style={style} />
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(labelXpx,labelYpx)`,
pointerEvents: 'all',
}}
className="nodrag nopan"
>
<button onClick={() => console.log('edge clicked', id)}>
{data?.label}
</button>
</div>
</EdgeLabelRenderer>
</>
);
}
```
## Edge Markers
```tsx
// Built-in markers
const edge = {
id: 'e1-2',
source: '1',
target: '2',
markerEnd: MarkerType.ArrowClosed,
// or custom:
markerEnd: {
type: MarkerType.Arrow,
color: '#f00',
width: 20,
height: 20,
},
};
```
Reviews React Flow code for anti-patterns, performance issues, and best practices. Use when reviewing code that uses @xyflow/react, checking for common mista...
---
name: react-flow-code-review
description: Reviews React Flow code for anti-patterns, performance issues, and best practices. Use when reviewing code that uses @xyflow/react, checking for common mistakes, or optimizing node-based UI implementations.
---
# React Flow Code Review
When reviewing React Flow code, complete the gates below in order. Each step has an objective pass condition before moving on.
## Review gates (sequenced)
1. **Locate flow code** — Search the review scope for `ReactFlow`, `ReactFlowProvider`, `useReactFlow`, `@xyflow/react`, `nodeTypes`, and `edgeTypes`. **Pass:** a short list of file paths (or explicit “none in scope” after searching).
2. **Provider boundary** — For each `useReactFlow()` (and other hooks that require the provider), trace the component tree to an enclosing `ReactFlowProvider`, or record a concrete mismatch with **file:line**.
3. **Stable types and memo surfaces** — For each custom node or edge component, note whether it uses `memo` and typed props (`NodeProps<...>`, etc.). For each `nodeTypes` / `edgeTypes` value passed into `<ReactFlow>`, confirm a stable reference (module scope, or `useMemo` with deps you can point to) or flag unstable recreation with **file:line**.
4. **Report with evidence** — For each finding you will deliver, record **file path and line number(s)** (or a minimal quoted snippet). **Pass:** no critical or high-severity issue is stated without that citation.
5. **Close the checklists** — Use [Performance Checklist](#performance-checklist) and [Common Mistakes](#common-mistakes); each item is **satisfied**, **not applicable** (with reason), or **open** with evidence. **Pass:** no item left silently ambiguous.
## Critical Anti-Patterns
### 1. Defining nodeTypes/edgeTypes Inside Components
**Problem**: Causes all nodes to re-mount on every render.
```tsx
// BAD - recreates object every render
function Flow() {
const nodeTypes = { custom: CustomNode }; // WRONG
return <ReactFlow nodeTypes={nodeTypes} />;
}
// GOOD - defined outside component
const nodeTypes = { custom: CustomNode };
function Flow() {
return <ReactFlow nodeTypes={nodeTypes} />;
}
// GOOD - useMemo if dynamic
function Flow() {
const nodeTypes = useMemo(() => ({ custom: CustomNode }), []);
return <ReactFlow nodeTypes={nodeTypes} />;
}
```
### 2. Missing memo() on Custom Nodes/Edges
**Problem**: Custom components re-render on every parent update.
```tsx
// BAD - no memoization
function CustomNode({ data }: NodeProps) {
return <div>{data.label}</div>;
}
// GOOD - wrapped in memo
import { memo } from 'react';
const CustomNode = memo(function CustomNode({ data }: NodeProps) {
return <div>{data.label}</div>;
});
```
### 3. Inline Callbacks Without useCallback
**Problem**: Creates new function references, breaking memoization.
```tsx
// BAD - inline callback
<ReactFlow
onNodesChange={(changes) => setNodes(applyNodeChanges(changes, nodes))}
/>
// GOOD - memoized callback
const onNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[]
);
<ReactFlow onNodesChange={onNodesChange} />
```
### 4. Using useReactFlow Outside Provider
```tsx
// BAD - will throw error
function App() {
const { getNodes } = useReactFlow(); // ERROR: No provider
return <ReactFlow ... />;
}
// GOOD - wrap in provider
function FlowContent() {
const { getNodes } = useReactFlow(); // Works
return <ReactFlow ... />;
}
function App() {
return (
<ReactFlowProvider>
<FlowContent />
</ReactFlowProvider>
);
}
```
### 5. Storing Complex Objects in Node Data
**Problem**: Reference equality checks fail, causing unnecessary updates.
```tsx
// BAD - new object reference every time
setNodes(nodes.map(n => ({
...n,
data: { ...n.data, config: { nested: 'value' } } // New object each time
})));
// GOOD - use updateNodeData for targeted updates
const { updateNodeData } = useReactFlow();
updateNodeData(nodeId, { config: { nested: 'value' } });
```
## Performance Checklist
### Node Rendering
- [ ] Custom nodes wrapped in `memo()`
- [ ] nodeTypes defined outside component or memoized
- [ ] Heavy computations inside nodes use `useMemo`
- [ ] Event handlers use `useCallback`
### Edge Rendering
- [ ] Custom edges wrapped in `memo()`
- [ ] edgeTypes defined outside component or memoized
- [ ] Edge path calculations are not duplicated
### State Updates
- [ ] Using functional form of setState: `setNodes((nds) => ...)`
- [ ] Not spreading entire state for single property updates
- [ ] Using `updateNodeData` for data-only changes
- [ ] Batch updates when adding multiple nodes/edges
### Viewport
- [ ] Not calling `fitView()` on every render
- [ ] Using `fitViewOptions` for initial fit only
- [ ] Animation durations are reasonable (< 500ms)
## Common Mistakes
### Missing Container Height
```tsx
// BAD - no height, flow won't render
<ReactFlow nodes={nodes} edges={edges} />
// GOOD - explicit dimensions
<div style={{ width: '100%', height: '100vh' }}>
<ReactFlow nodes={nodes} edges={edges} />
</div>
```
### Missing CSS Import
```tsx
// Required for default styles
import '@xyflow/react/dist/style.css';
```
### Forgetting nodrag on Interactive Elements
```tsx
// BAD - clicking button drags node
<button onClick={handleClick}>Click</button>
// GOOD - prevents drag
<button className="nodrag" onClick={handleClick}>Click</button>
```
### Not Using Position Constants
```tsx
// BAD - string literals
<Handle type="source" position="right" />
// GOOD - type-safe constants
import { Position } from '@xyflow/react';
<Handle type="source" position={Position.Right} />
```
### Mutating Nodes/Edges Directly
```tsx
// BAD - direct mutation
nodes[0].position = { x: 100, y: 100 };
setNodes(nodes);
// GOOD - immutable update
setNodes(nodes.map(n =>
n.id === '1' ? { ...n, position: { x: 100, y: 100 } } : n
));
```
## TypeScript Issues
### Missing Generic Types
```tsx
// BAD - loses type safety
const [nodes, setNodes] = useNodesState(initialNodes);
// GOOD - explicit types
type MyNode = Node<{ value: number }, 'custom'>;
const [nodes, setNodes] = useNodesState<MyNode>(initialNodes);
```
### Wrong Props Type
```tsx
// BAD - using wrong type
function CustomNode(props: any) { ... }
// GOOD - correct props type
function CustomNode(props: NodeProps<MyNode>) { ... }
```
## Review Questions
1. Are all custom components memoized?
2. Are nodeTypes/edgeTypes defined outside render?
3. Are callbacks wrapped in useCallback?
4. Is the container sized properly?
5. Are styles imported?
6. Is useReactFlow used inside a provider?
7. Are interactive elements marked with nodrag?
8. Are types used consistently throughout?