feat: adding new skills, including testing patterns and methodologies, along with bundled resources for better usability.

This commit is contained in:
duthaho
2026-03-30 12:18:00 +07:00
parent 0ff5ae4082
commit 7fa9a48c6c
89 changed files with 25808 additions and 923 deletions
+662 -38
View File
@@ -1,91 +1,715 @@
---
name: django
description: >
Use this skill when working with Django web applications, Django ORM models, Django REST Framework APIs, or Django admin interfaces. Trigger for any mention of Django views, serializers, migrations, URL routing, class-based views, or Django middleware. Also applies when building Python web apps with templates, managing database schemas through Django migrations, or setting up admin panels.
---
# Django
## Description
Django web framework with ORM, views, and REST framework patterns.
## When to Use
- Python web applications
- Admin interfaces
- Django REST Framework APIs
- Content-heavy sites with ORM-driven data models
## When NOT to Use
- FastAPI projects — use the `frameworks/fastapi` skill instead for async APIs and microservices
- JavaScript/Node.js backends (Express, NestJS) — this skill is Python-only
- Microservices architectures — consider FastAPI instead for lightweight, async services
---
## Core Patterns
### Models
### 1. Models & ORM
#### Field types and relationships
```python
from django.db import models
from django.utils import timezone
class Organization(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class User(models.Model):
class Role(models.TextChoices):
ADMIN = "admin", "Administrator"
MEMBER = "member", "Member"
VIEWER = "viewer", "Viewer"
email = models.EmailField(unique=True)
name = models.CharField(max_length=100)
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="members",
)
role = models.CharField(max_length=20, choices=Role.choices, default=Role.MEMBER)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
ordering = ["-created_at"]
indexes = [
models.Index(fields=["email"]),
models.Index(fields=["organization", "role"]),
]
constraints = [
models.UniqueConstraint(
fields=["organization", "email"],
name="unique_org_email",
),
]
def __str__(self):
return self.email
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
class Project(models.Model):
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owned_projects")
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
tags = models.ManyToManyField(Tag, blank=True, related_name="projects")
# OneToOneField for 1:1 relationships
settings = models.OneToOneField(
"ProjectSettings", on_delete=models.CASCADE, null=True, blank=True
)
class ProjectSettings(models.Model):
is_public = models.BooleanField(default=False)
max_members = models.IntegerField(default=10)
```
### Views (Class-based)
#### Custom managers and QuerySet methods
```python
from django.views.generic import ListView, DetailView
class ActiveManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
class UserListView(ListView):
model = User
template_name = 'users/list.html'
context_object_name = 'users'
class UserQuerySet(models.QuerySet):
def admins(self):
return self.filter(role=User.Role.ADMIN)
def in_organization(self, org_id):
return self.filter(organization_id=org_id)
def with_project_count(self):
return self.annotate(project_count=models.Count("owned_projects"))
class User(models.Model):
# ... fields ...
objects = UserQuerySet.as_manager()
active = ActiveManager()
```
#### F objects, Q objects, and annotations
```python
from django.db.models import F, Q, Count, Avg, Sum, Value, When, Case
# F objects: reference model fields in queries
Project.objects.filter(updated_at__gt=F("created_at"))
User.objects.update(login_count=F("login_count") + 1) # Atomic increment
# Q objects: complex lookups with OR, AND, NOT
User.objects.filter(
Q(role="admin") | Q(role="member"),
~Q(is_active=False), # NOT inactive
)
# Annotations and aggregations
orgs = Organization.objects.annotate(
member_count=Count("members"),
admin_count=Count("members", filter=Q(members__role="admin")),
avg_projects=Avg("members__owned_projects"),
).filter(member_count__gte=5)
# Conditional expressions
users = User.objects.annotate(
tier=Case(
When(owned_projects__count__gte=10, then=Value("power")),
When(owned_projects__count__gte=3, then=Value("active")),
default=Value("starter"),
)
)
# Subqueries
from django.db.models import Subquery, OuterRef
latest_project = Project.objects.filter(
owner=OuterRef("pk")
).order_by("-created_at").values("title")[:1]
users = User.objects.annotate(latest_project_title=Subquery(latest_project))
```
### 2. Views
#### Function-based views
```python
from django.shortcuts import render, get_object_or_404, redirect
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
@login_required
def project_detail(request, project_id):
project = get_object_or_404(
Project.objects.select_related("owner", "organization"),
pk=project_id,
)
if request.method == "POST":
form = ProjectForm(request.POST, instance=project)
if form.is_valid():
form.save()
return redirect("project-detail", project_id=project.id)
else:
form = ProjectForm(instance=project)
return render(request, "projects/detail.html", {
"project": project,
"form": form,
})
```
#### Class-based views
```python
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.urls import reverse_lazy
class ProjectListView(LoginRequiredMixin, ListView):
model = Project
template_name = "projects/list.html"
context_object_name = "projects"
paginate_by = 20
class UserDetailView(DetailView):
model = User
template_name = 'users/detail.html'
def get_queryset(self):
qs = super().get_queryset().select_related("owner", "organization")
search = self.request.GET.get("q")
if search:
qs = qs.filter(
Q(title__icontains=search) | Q(description__icontains=search)
)
return qs
class ProjectCreateView(LoginRequiredMixin, CreateView):
model = Project
form_class = ProjectForm
template_name = "projects/form.html"
success_url = reverse_lazy("project-list")
def form_valid(self, form):
form.instance.owner = self.request.user
form.instance.organization = self.request.user.organization
return super().form_valid(form)
class ProjectDeleteView(PermissionRequiredMixin, DeleteView):
model = Project
permission_required = "projects.delete_project"
success_url = reverse_lazy("project-list")
```
### Django REST Framework
#### Mixins for reuse
```python
from rest_framework import serializers, viewsets
class OrganizationFilterMixin:
"""Filter queryset to the current user's organization."""
def get_queryset(self):
return super().get_queryset().filter(
organization=self.request.user.organization
)
class UserSerializer(serializers.ModelSerializer):
class ProjectListView(LoginRequiredMixin, OrganizationFilterMixin, ListView):
model = Project
# queryset is automatically filtered by organization
```
#### API views with Django REST Framework
```python
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def project_list(request):
if request.method == "GET":
projects = Project.objects.filter(organization=request.user.organization)
serializer = ProjectSerializer(projects, many=True)
return Response(serializer.data)
serializer = ProjectCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(owner=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
```
### 3. Migrations
#### Creating and running migrations
```bash
# Generate migrations after model changes
python manage.py makemigrations app_name
# Preview SQL without applying
python manage.py sqlmigrate app_name 0001
# Apply migrations
python manage.py migrate
# Show migration status
python manage.py showmigrations
```
#### Data migrations with RunPython
```python
from django.db import migrations
def populate_slugs(apps, schema_editor):
Organization = apps.get_model("myapp", "Organization")
from django.utils.text import slugify
for org in Organization.objects.filter(slug=""):
org.slug = slugify(org.name)
org.save(update_fields=["slug"])
def reverse_populate_slugs(apps, schema_editor):
pass # No-op reverse
class Migration(migrations.Migration):
dependencies = [
("myapp", "0005_add_slug_field"),
]
operations = [
migrations.RunPython(populate_slugs, reverse_populate_slugs),
]
```
#### Squashing migrations
```bash
# Squash migrations 0001 through 0010 into one
python manage.py squashmigrations app_name 0001 0010
```
**Tips:**
- Always provide a reverse function for `RunPython` (even if it is a no-op)
- Use `apps.get_model()` in data migrations, never import models directly
- Test migrations on a copy of production data before deploying
### 4. Forms
#### ModelForm with custom validation
```python
from django import forms
from django.core.exceptions import ValidationError
class ProjectForm(forms.ModelForm):
class Meta:
model = User
fields = ['id', 'email', 'name', 'created_at']
model = Project
fields = ["title", "description", "tags"]
widgets = {
"description": forms.Textarea(attrs={"rows": 4}),
"tags": forms.CheckboxSelectMultiple(),
}
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
def clean_title(self):
title = self.cleaned_data["title"]
if "test" in title.lower() and not self.instance.pk:
raise ValidationError("Title cannot contain 'test' for new projects.")
return title
def clean(self):
cleaned = super().clean()
title = cleaned.get("title", "")
description = cleaned.get("description", "")
if len(title) + len(description) < 20:
raise ValidationError("Title + description must be at least 20 characters.")
return cleaned
```
### URLs
#### Formsets
```python
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from django.forms import inlineformset_factory
router = DefaultRouter()
router.register('users', UserViewSet)
TaskFormSet = inlineformset_factory(
Project,
Task,
fields=["title", "assigned_to", "due_date"],
extra=2, # Number of empty forms
can_delete=True,
max_num=20,
)
urlpatterns = [
path('api/', include(router.urls)),
# In a view
def project_tasks(request, project_id):
project = get_object_or_404(Project, pk=project_id)
if request.method == "POST":
formset = TaskFormSet(request.POST, instance=project)
if formset.is_valid():
formset.save()
return redirect("project-detail", project_id=project.id)
else:
formset = TaskFormSet(instance=project)
return render(request, "projects/tasks.html", {"formset": formset})
```
### 5. Signals
```python
from django.db.models.signals import post_save, pre_save, m2m_changed
from django.dispatch import receiver
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
@receiver(pre_save, sender=Project)
def set_project_slug(sender, instance, **kwargs):
if not instance.slug:
from django.utils.text import slugify
instance.slug = slugify(instance.title)
# Custom signals
from django.dispatch import Signal
project_published = Signal() # Accepts sender
@receiver(project_published)
def notify_members(sender, project, **kwargs):
for member in project.organization.members.all():
send_notification(member, f"Project '{project.title}' published")
# Firing a custom signal
project_published.send(sender=Project, project=project)
```
**When to use signals vs overriding `save()`:**
- Use signals when the action is a side effect (notifications, logging, cache invalidation)
- Override `save()` when the logic is core to the model's behavior (setting computed fields)
### 6. Middleware
```python
import time
from django.utils.deprecation import MiddlewareMixin
class TimingMiddleware(MiddlewareMixin):
def process_request(self, request):
request._start_time = time.perf_counter()
def process_response(self, request, response):
if hasattr(request, "_start_time"):
duration = time.perf_counter() - request._start_time
response["X-Process-Time"] = f"{duration:.4f}"
return response
# New-style middleware (function-based)
def organization_middleware(get_response):
def middleware(request):
if request.user.is_authenticated:
request.organization = request.user.organization
else:
request.organization = None
response = get_response(request)
return response
return middleware
# Register in settings.py
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"myapp.middleware.organization_middleware", # Custom
"myapp.middleware.TimingMiddleware", # Custom
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
```
### 7. Django REST Framework
#### Serializers
```python
from rest_framework import serializers
class UserSerializer(serializers.ModelSerializer):
project_count = serializers.IntegerField(read_only=True)
full_name = serializers.SerializerMethodField()
class Meta:
model = User
fields = ["id", "email", "name", "role", "full_name", "project_count", "created_at"]
read_only_fields = ["id", "created_at"]
def get_full_name(self, obj):
return f"{obj.name} ({obj.role})"
class ProjectSerializer(serializers.ModelSerializer):
owner = UserSerializer(read_only=True)
tags = serializers.SlugRelatedField(
many=True, slug_field="name", queryset=Tag.objects.all()
)
class Meta:
model = Project
fields = ["id", "title", "description", "owner", "tags", "created_at"]
def validate_title(self, value):
if len(value) < 3:
raise serializers.ValidationError("Title must be at least 3 characters.")
return value
class ProjectCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = ["title", "description", "tags"]
```
#### ViewSets and routers
```python
from rest_framework import viewsets, permissions, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
class ProjectViewSet(viewsets.ModelViewSet):
serializer_class = ProjectSerializer
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["owner", "tags"]
search_fields = ["title", "description"]
ordering_fields = ["created_at", "title"]
ordering = ["-created_at"]
def get_queryset(self):
return Project.objects.filter(
organization=self.request.user.organization
).select_related("owner").prefetch_related("tags")
def get_serializer_class(self):
if self.action == "create":
return ProjectCreateSerializer
return ProjectSerializer
def perform_create(self, serializer):
serializer.save(
owner=self.request.user,
organization=self.request.user.organization,
)
@action(detail=True, methods=["post"])
def publish(self, request, pk=None):
project = self.get_object()
project.is_published = True
project.save(update_fields=["is_published"])
return Response({"status": "published"})
# urls.py
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register("projects", ProjectViewSet, basename="project")
router.register("users", UserViewSet, basename="user")
urlpatterns = [
path("api/", include(router.urls)),
]
```
#### Permissions
```python
from rest_framework.permissions import BasePermission
class IsOrganizationAdmin(BasePermission):
def has_permission(self, request, view):
return (
request.user.is_authenticated
and request.user.role == User.Role.ADMIN
)
class IsOwnerOrReadOnly(BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.owner == request.user
```
#### Pagination
```python
from rest_framework.pagination import PageNumberPagination, CursorPagination
class StandardPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
class TimelinePagination(CursorPagination):
page_size = 50
ordering = "-created_at"
# settings.py
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "myapp.pagination.StandardPagination",
"PAGE_SIZE": 20,
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
}
```
### 8. Admin
```python
from django.contrib import admin
from django.utils.html import format_html
class TaskInline(admin.TabularInline):
model = Task
extra = 0
fields = ["title", "assigned_to", "status", "due_date"]
readonly_fields = ["created_at"]
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ["title", "owner_name", "organization", "tag_list", "created_at"]
list_filter = ["organization", "tags", "created_at"]
search_fields = ["title", "description", "owner__email"]
readonly_fields = ["created_at", "updated_at"]
autocomplete_fields = ["owner", "organization"]
prepopulated_fields = {"slug": ("title",)}
date_hierarchy = "created_at"
inlines = [TaskInline]
fieldsets = (
(None, {
"fields": ("title", "slug", "description"),
}),
("Ownership", {
"fields": ("owner", "organization", "tags"),
}),
("Metadata", {
"classes": ("collapse",),
"fields": ("created_at", "updated_at"),
}),
)
def owner_name(self, obj):
return obj.owner.name
owner_name.short_description = "Owner"
owner_name.admin_order_field = "owner__name"
def tag_list(self, obj):
return ", ".join(t.name for t in obj.tags.all())
tag_list.short_description = "Tags"
def get_queryset(self, request):
return super().get_queryset(request).select_related(
"owner", "organization"
).prefetch_related("tags")
# Custom admin actions
@admin.action(description="Mark selected projects as published")
def make_published(self, request, queryset):
count = queryset.update(is_published=True)
self.message_user(request, f"{count} projects published.")
actions = [make_published]
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ["email", "name", "organization", "role", "is_active"]
list_filter = ["role", "is_active", "organization"]
search_fields = ["email", "name"]
list_editable = ["role", "is_active"]
list_per_page = 50
```
---
## Best Practices
1. Use class-based views for standard CRUD
2. Define model methods for business logic
3. Use serializers for validation
4. Add proper permissions
5. Use select_related/prefetch_related for queries
1. **Use `select_related` and `prefetch_related` on every query that touches relations**`select_related` for ForeignKey/OneToOne (SQL JOIN), `prefetch_related` for ManyToMany and reverse ForeignKey (separate query). Check queries with `django-debug-toolbar`.
2. **Keep business logic in model methods or service functions, not in views** — views should handle HTTP, forms should handle validation, models/services should handle domain logic. This makes code testable without needing HTTP.
3. **Use `get_queryset()` for dynamic filtering instead of hardcoding querysets** — both in views and DRF ViewSets. This enables mixin composition and per-request filtering (e.g., by organization).
4. **Write data migrations for schema changes that require backfills** — never assume fields can be added as non-nullable without a migration to populate existing rows. Use `RunPython` with a reverse function.
5. **Configure Django REST Framework defaults in settings** — set `DEFAULT_PAGINATION_CLASS`, `DEFAULT_PERMISSION_CLASSES`, `DEFAULT_AUTHENTICATION_CLASSES` in `REST_FRAMEWORK` dict to avoid repeating yourself on each ViewSet.
6. **Use `TextChoices` / `IntegerChoices` for enum fields** — they integrate with admin filters, serializer validation, and migrations automatically. Avoid plain strings or integers for status/role fields.
7. **Index frequently queried fields** — add `db_index=True` on individual fields or use `Meta.indexes` for composite indexes. Add `UniqueConstraint` for business-rule uniqueness.
8. **Use Django's `transaction.atomic()` for multi-step writes** — wrap create/update sequences that must succeed or fail together. DRF's `perform_create` and `perform_update` are good places for this.
```python
from django.db import transaction
@transaction.atomic
def transfer_project(project, new_owner):
old_owner = project.owner
project.owner = new_owner
project.save(update_fields=["owner"])
AuditLog.objects.create(
action="transfer",
project=project,
from_user=old_owner,
to_user=new_owner,
)
```
---
## Common Pitfalls
- **N+1 queries**: Use select_related/prefetch_related
- **Missing migrations**: Run makemigrations
- **No validation**: Use serializers properly
1. **N+1 queries** — accessing `project.owner.name` in a loop without `select_related("owner")` fires one query per iteration. Use `django-debug-toolbar` or `nplusone` to detect these. Always optimize queryset in `get_queryset()`.
2. **Importing models directly in data migrations** — models change over time, but migrations are frozen. Always use `apps.get_model("app_name", "ModelName")` inside `RunPython` functions, never `from myapp.models import Model`.
3. **Forgetting to call `full_clean()` in model saves** — Django's `save()` does NOT run validators by default. Only forms and serializers call `full_clean()`. If you save models directly, add explicit validation.
4. **Circular imports between apps** — referencing models across apps can cause import cycles. Use string references in ForeignKey: `models.ForeignKey("other_app.ModelName", ...)` instead of importing the class.
5. **Overusing signals for core logic** — signals make code harder to trace and debug. Use them for side effects (sending emails, cache invalidation), not for core domain logic. If logic should always run on save, override `save()` instead.
6. **Returning entire QuerySets from service functions** — QuerySets are lazy, which is usually good, but returning them from service layers can lead to unexpected queries executing in templates. Use `.values()`, `.values_list()`, or serialize to dicts when crossing layer boundaries.
---
## Related Skills
- `languages/python` — Python language patterns and best practices
- `databases/postgresql` — Database integration and query optimization
- `testing/pytest` — Testing Django applications with pytest-django
@@ -0,0 +1,250 @@
# Django Patterns Quick Reference
## QuerySet Patterns
### select_related (FK/OneToOne - single JOIN)
```python
# BAD: N+1 queries
for order in Order.objects.all():
print(order.customer.name) # Hits DB each iteration
# GOOD: 1 query with JOIN
for order in Order.objects.select_related("customer"):
print(order.customer.name)
# Chain through multiple FKs
Order.objects.select_related("customer__company")
```
### prefetch_related (M2M/reverse FK - separate query)
```python
# BAD: N+1 on reverse FK
for author in Author.objects.all():
print(author.book_set.all()) # Query per author
# GOOD: 2 queries total
for author in Author.objects.prefetch_related("books"):
print(author.books.all())
# Custom prefetch with filtering
from django.db.models import Prefetch
Author.objects.prefetch_related(
Prefetch("books", queryset=Book.objects.filter(published=True), to_attr="published_books")
)
```
### F Objects (reference model fields in queries)
```python
from django.db.models import F
Product.objects.filter(stock__lt=F("reorder_level")) # Compare fields
Product.objects.filter(id=1).update(stock=F("stock") - 1) # Atomic update
Order.objects.filter(amount__gt=F("customer__credit_limit")) # Across relations
```
### Q Objects (complex lookups with OR/NOT)
```python
from django.db.models import Q
# OR
User.objects.filter(Q(role="admin") | Q(is_superuser=True))
# NOT
User.objects.filter(~Q(status="banned"))
# Complex combinations
User.objects.filter(
(Q(role="admin") | Q(role="staff")) & ~Q(status="inactive")
)
# Dynamic query building
conditions = Q()
if name: conditions &= Q(name__icontains=name)
if email: conditions &= Q(email__icontains=email)
User.objects.filter(conditions)
```
### Subquery and OuterRef
```python
from django.db.models import Subquery, OuterRef, Exists
# Subquery: latest order date per customer
latest_order = Order.objects.filter(
customer=OuterRef("pk")
).order_by("-created_at").values("created_at")[:1]
Customer.objects.annotate(last_order=Subquery(latest_order))
# Exists: customers with orders
Customer.objects.annotate(
has_orders=Exists(Order.objects.filter(customer=OuterRef("pk")))
).filter(has_orders=True)
```
### Aggregation
```python
from django.db.models import Count, Sum, Avg
# Aggregate (returns dict)
Order.objects.aggregate(total=Sum("amount"), avg=Avg("amount"))
# Annotate (per-row)
Customer.objects.annotate(order_count=Count("orders"))
```
---
## Model Patterns
### Abstract Base Model
```python
class TimestampMixin(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True # No DB table created
class Order(TimestampMixin):
amount = models.DecimalField(max_digits=10, decimal_places=2)
# Inherits created_at, updated_at
```
### Proxy Model (same table, different behavior)
```python
class PendingOrderManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(status="pending")
class PendingOrder(Order):
objects = PendingOrderManager()
class Meta:
proxy = True # Same DB table as Order
```
### Custom Manager and QuerySet
```python
class PublishedQuerySet(models.QuerySet):
def published(self):
return self.filter(status="published")
def by_author(self, author):
return self.filter(author=author)
class Article(models.Model):
objects = PublishedQuerySet.as_manager()
# Chainable: Article.objects.published().by_author(user)
```
---
## View Patterns
### Class-Based View Mixins
| Mixin | Purpose |
|-------|---------|
| `LoginRequiredMixin` | Require authentication |
| `PermissionRequiredMixin` | Require specific permission |
| `UserPassesTestMixin` | Custom permission test |
| `FormView` | Handle form display + submission |
| `CreateView` / `UpdateView` | Model form CRUD |
| `ListView` / `DetailView` | Read operations |
```python
class OrderListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
model = Order
permission_required = "orders.view_order"
paginate_by = 25
def get_queryset(self):
return super().get_queryset().filter(customer=self.request.user)
```
---
## Django REST Framework Patterns
### Nested Serializers
```python
class AddressSerializer(serializers.ModelSerializer):
class Meta:
model = Address
fields = ["street", "city", "zip_code"]
class CustomerSerializer(serializers.ModelSerializer):
address = AddressSerializer()
class Meta:
model = Customer
fields = ["id", "name", "address"]
def create(self, validated_data):
address_data = validated_data.pop("address")
address = Address.objects.create(**address_data)
return Customer.objects.create(address=address, **validated_data)
```
### Custom Permissions
```python
from rest_framework.permissions import BasePermission
class IsOwner(BasePermission):
def has_object_permission(self, request, view, obj):
return obj.owner == request.user
class IsAdminOrReadOnly(BasePermission):
def has_permission(self, request, view):
if request.method in ("GET", "HEAD", "OPTIONS"):
return True
return request.user.is_staff
# Usage
class OrderViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsOwner]
```
### ViewSet Actions
```python
from rest_framework.decorators import action
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer
@action(detail=True, methods=["post"])
def cancel(self, request, pk=None):
order = self.get_object()
order.cancel()
return Response({"status": "cancelled"})
@action(detail=False, methods=["get"])
def summary(self, request):
return Response(self.get_queryset().aggregate(total=Sum("amount"), count=Count("id")))
```
### Filtering (django-filter)
```python
class OrderFilter(django_filters.FilterSet):
min_amount = django_filters.NumberFilter(field_name="amount", lookup_expr="gte")
max_amount = django_filters.NumberFilter(field_name="amount", lookup_expr="lte")
class Meta:
model = Order
fields = ["status", "customer"]
```
+638 -47
View File
@@ -1,89 +1,680 @@
---
name: fastapi
description: >
Use this skill when building REST APIs with Python and FastAPI, creating async web applications, or generating OpenAPI/Swagger documentation. Trigger for any mention of FastAPI, Pydantic models, async Python endpoints, dependency injection in Python APIs, or APIRouter patterns. Also applies when setting up Python microservices, adding request validation with Pydantic, or configuring ASGI applications.
---
# FastAPI
## Description
FastAPI web framework with async patterns, Pydantic validation, and OpenAPI documentation.
## When to Use
- Building REST APIs with Python
- Async web applications
- OpenAPI/Swagger documentation needed
- Python microservices
- WebSocket real-time applications
## When NOT to Use
- Django projects — use the `frameworks/django` skill instead
- JavaScript/Node.js backends (Express, NestJS) — this skill is Python-only
- Non-API applications such as CLI tools, desktop apps, or batch processing scripts
---
## Core Patterns
### Route Definition
### 1. Project Structure
Recommended layout for medium-large FastAPI applications:
```
project/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app creation, startup/shutdown
│ ├── config.py # Settings via pydantic-settings
│ ├── dependencies.py # Shared dependencies
│ ├── exceptions.py # Custom exception handlers
│ ├── middleware.py # Custom middleware
│ ├── api/
│ │ ├── __init__.py
│ │ ├── router.py # Root router aggregating all sub-routers
│ │ ├── v1/
│ │ │ ├── __init__.py
│ │ │ ├── users.py # /api/v1/users endpoints
│ │ │ ├── items.py # /api/v1/items endpoints
│ │ │ └── auth.py # /api/v1/auth endpoints
│ │ └── v2/ # Future API version
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py # SQLAlchemy / SQLModel ORM models
│ │ └── item.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py # Pydantic request/response schemas
│ │ └── item.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── user_service.py # Business logic layer
│ │ └── item_service.py
│ ├── repositories/
│ │ ├── __init__.py
│ │ ├── user_repo.py # Data access layer
│ │ └── item_repo.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── database.py # DB engine, session factory
│ │ └── security.py # JWT, hashing, auth utils
│ └── tests/
│ ├── __init__.py
│ ├── conftest.py # Fixtures: test client, test DB
│ ├── test_users.py
│ └── test_items.py
├── alembic/ # Database migrations
│ ├── env.py
│ └── versions/
├── alembic.ini
├── pyproject.toml
├── Dockerfile
└── .env
```
**Key conventions:**
- Separate `schemas/` (Pydantic) from `models/` (ORM) to keep concerns clean
- Use `services/` for business logic, `repositories/` for data access
- Version API routes under `api/v1/`, `api/v2/` for backward compatibility
- Keep `main.py` thin — it only wires things together
```python
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.api.router import api_router
from app.config import settings
from app.core.database import engine
from app.middleware import add_middleware
app = FastAPI()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create tables, warm caches, connect to services
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown: close connections, flush buffers
await engine.dispose()
class UserCreate(BaseModel):
email: str
name: str
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
lifespan=lifespan,
)
add_middleware(app)
app.include_router(api_router, prefix="/api")
```
class UserResponse(BaseModel):
id: int
email: str
name: str
### 2. Route Patterns
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
# Create user logic
return UserResponse(id=1, **user.model_dump())
#### APIRouter with tags, prefixes, and dependencies
@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
user = await get_user_by_id(user_id)
```python
from fastapi import APIRouter, Depends, Query, Path, Body, HTTPException, status
from app.schemas.user import UserCreate, UserResponse, UserUpdate, UserList
from app.dependencies import get_current_user
router = APIRouter(
prefix="/users",
tags=["users"],
dependencies=[Depends(get_current_user)], # Applied to all routes
responses={401: {"description": "Not authenticated"}},
)
# Path parameters with validation
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int = Path(..., gt=0, description="The ID of the user to retrieve"),
):
user = await user_service.get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
# Query parameters with defaults and validation
@router.get("/", response_model=UserList)
async def list_users(
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(20, ge=1, le=100, description="Max records to return"),
search: str | None = Query(None, min_length=1, max_length=100),
sort_by: str = Query("created_at", pattern="^(created_at|name|email)$"),
):
users = await user_service.list(skip=skip, limit=limit, search=search)
return users
# Request body with status codes
@router.post(
"/",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new user",
description="Creates a user account and sends a welcome email.",
)
async def create_user(user: UserCreate = Body(...)):
return await user_service.create(user)
# Multiple response models for different status codes
@router.put("/{user_id}", response_model=UserResponse, responses={
404: {"description": "User not found"},
409: {"description": "Email already taken"},
})
async def update_user(user_id: int, user: UserUpdate):
return await user_service.update(user_id, user)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(user_id: int):
await user_service.delete(user_id)
```
### Dependency Injection
#### Router aggregation
```python
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
# app/api/router.py
from fastapi import APIRouter
from app.api.v1 import users, items, auth
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/v1")
api_router.include_router(users.router, prefix="/v1")
api_router.include_router(items.router, prefix="/v1")
```
### 3. Dependency Injection
#### Basic dependency with Depends()
```python
from fastapi import Depends, Header, HTTPException
async def verify_api_key(x_api_key: str = Header(...)):
if x_api_key != settings.API_KEY:
raise HTTPException(status_code=403, detail="Invalid API key")
return x_api_key
```
#### Nested dependencies
```python
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
yield session # yield dependency — cleanup runs after response
@app.get("/users")
async def list_users(db: AsyncSession = Depends(get_db)):
return await db.execute(select(User))
async def get_user_repo(db: AsyncSession = Depends(get_db)) -> UserRepository:
return UserRepository(db)
async def get_user_service(
repo: UserRepository = Depends(get_user_repo),
) -> UserService:
return UserService(repo)
@router.get("/users")
async def list_users(service: UserService = Depends(get_user_service)):
return await service.list_all()
```
### Router Organization
#### Yield dependencies for cleanup
```python
from fastapi import APIRouter
async def get_redis() -> AsyncGenerator[Redis, None]:
redis = await aioredis.from_url(settings.REDIS_URL)
try:
yield redis
finally:
await redis.close() # Always runs, even on exceptions
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/")
async def list_users():
pass
# In main.py
app.include_router(router)
async def get_http_client() -> AsyncGenerator[httpx.AsyncClient, None]:
async with httpx.AsyncClient(timeout=30.0) as client:
yield client
```
#### Request-scoped dependencies with caching
```python
# FastAPI caches dependency results per-request by default.
# The same db session is reused if multiple deps request it.
@router.get("/dashboard")
async def dashboard(
user_service: UserService = Depends(get_user_service),
item_service: ItemService = Depends(get_item_service),
# Both services share the same db session from get_db()
):
users = await user_service.count()
items = await item_service.count()
return {"users": users, "items": items}
# To disable caching (get fresh instance each time):
@router.get("/example")
async def example(
db1: AsyncSession = Depends(get_db),
db2: AsyncSession = Depends(get_db, use_cache=False),
# db1 and db2 are different sessions
):
pass
```
#### Class-based dependencies
```python
class Pagination:
def __init__(
self,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
):
self.skip = skip
self.limit = limit
@router.get("/items")
async def list_items(pagination: Pagination = Depends()):
# pagination.skip, pagination.limit
pass
```
### 4. Middleware
#### Custom middleware
```python
import time
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
response.headers["X-Process-Time"] = f"{duration:.4f}"
return response
```
#### Pure ASGI middleware (higher performance)
```python
from starlette.types import ASGIApp, Receive, Scope, Send
class RequestIDMiddleware:
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] == "http":
request_id = uuid.uuid4().hex
scope.setdefault("state", {})["request_id"] = request_id
async def send_with_header(message):
if message["type"] == "http.response.start":
headers = dict(message.get("headers", []))
headers[b"x-request-id"] = request_id.encode()
message["headers"] = list(headers.items())
await send(message)
await self.app(scope, receive, send_with_header)
else:
await self.app(scope, receive, send)
```
#### Standard middleware configuration
```python
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.middleware.gzip import GZipMiddleware
def add_middleware(app: FastAPI):
# Order matters: first added = outermost (runs first on request, last on response)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS, # ["https://example.com"]
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
allow_headers=["*"],
)
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=settings.ALLOWED_HOSTS, # ["example.com", "*.example.com"]
)
app.add_middleware(GZipMiddleware, minimum_size=500) # Compress responses > 500 bytes
app.add_middleware(TimingMiddleware)
```
### 5. Background Tasks
#### Simple background tasks
```python
from fastapi import BackgroundTasks
async def send_welcome_email(email: str, name: str):
# This runs after the response is sent
await email_service.send(
to=email,
subject="Welcome!",
body=f"Hello {name}, welcome to our platform.",
)
async def log_activity(user_id: int, action: str):
await activity_repo.create(user_id=user_id, action=action)
@router.post("/users", status_code=201)
async def create_user(
user: UserCreate,
background_tasks: BackgroundTasks,
):
new_user = await user_service.create(user)
# Queue multiple background tasks
background_tasks.add_task(send_welcome_email, new_user.email, new_user.name)
background_tasks.add_task(log_activity, new_user.id, "account_created")
return new_user
```
#### Long-running tasks with task queues
For tasks that take more than a few seconds, use a proper task queue:
```python
from celery import Celery
celery_app = Celery("worker", broker=settings.CELERY_BROKER_URL)
@celery_app.task
def generate_report(report_id: int):
# Long-running: query data, build PDF, upload to S3
...
@router.post("/reports", status_code=202)
async def request_report(params: ReportRequest):
report = await report_service.create(params)
generate_report.delay(report.id) # Dispatch to Celery worker
return {"report_id": report.id, "status": "processing"}
@router.get("/reports/{report_id}/status")
async def report_status(report_id: int):
report = await report_service.get(report_id)
return {"status": report.status, "url": report.download_url}
```
### 6. WebSocket
#### WebSocket endpoint with connection management
```python
from fastapi import WebSocket, WebSocketDisconnect
class ConnectionManager:
def __init__(self):
self.active_connections: dict[str, list[WebSocket]] = {}
async def connect(self, websocket: WebSocket, room: str):
await websocket.accept()
self.active_connections.setdefault(room, []).append(websocket)
def disconnect(self, websocket: WebSocket, room: str):
self.active_connections.get(room, []).remove(websocket)
async def broadcast(self, message: str, room: str):
for connection in self.active_connections.get(room, []):
try:
await connection.send_text(message)
except WebSocketDisconnect:
self.disconnect(connection, room)
manager = ConnectionManager()
@app.websocket("/ws/{room}")
async def websocket_endpoint(websocket: WebSocket, room: str):
await manager.connect(websocket, room)
try:
while True:
data = await websocket.receive_text()
await manager.broadcast(f"Message: {data}", room)
except WebSocketDisconnect:
manager.disconnect(websocket, room)
await manager.broadcast(f"User left the room", room)
```
#### WebSocket with authentication
```python
@app.websocket("/ws/private")
async def private_ws(websocket: WebSocket, token: str = Query(...)):
try:
user = verify_token(token)
except InvalidToken:
await websocket.close(code=4001, reason="Invalid token")
return
await websocket.accept()
try:
while True:
data = await websocket.receive_json()
response = await process_message(user, data)
await websocket.send_json(response)
except WebSocketDisconnect:
pass
```
### 7. File Handling
#### Upload files
```python
from fastapi import UploadFile, File
@router.post("/upload")
async def upload_file(
file: UploadFile = File(..., description="File to upload"),
):
# Validate file type and size
if file.content_type not in ["image/png", "image/jpeg"]:
raise HTTPException(400, "Only PNG and JPEG images are allowed")
if file.size and file.size > 5 * 1024 * 1024: # 5 MB
raise HTTPException(400, "File too large (max 5 MB)")
contents = await file.read()
path = f"uploads/{uuid.uuid4()}_{file.filename}"
async with aiofiles.open(path, "wb") as f:
await f.write(contents)
return {"filename": file.filename, "path": path, "size": len(contents)}
# Multiple file upload
@router.post("/upload-multiple")
async def upload_multiple(files: list[UploadFile] = File(...)):
results = []
for file in files:
contents = await file.read()
results.append({"filename": file.filename, "size": len(contents)})
return results
```
#### Streaming responses
```python
from fastapi.responses import StreamingResponse
import csv
import io
@router.get("/export/users")
async def export_users():
async def generate_csv():
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "name", "email"])
yield output.getvalue()
output.seek(0)
output.truncate(0)
async for user in user_service.stream_all():
writer.writerow([user.id, user.name, user.email])
yield output.getvalue()
output.seek(0)
output.truncate(0)
return StreamingResponse(
generate_csv(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=users.csv"},
)
```
#### Static files
```python
from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static")
```
### 8. Testing
#### TestClient for synchronous tests
```python
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_create_user():
response = client.post("/api/v1/users", json={
"email": "test@example.com",
"name": "Test User",
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@example.com"
def test_get_user_not_found():
response = client.get("/api/v1/users/99999")
assert response.status_code == 404
```
#### Async testing with httpx
```python
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def async_client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.mark.anyio
async def test_list_users(async_client: AsyncClient):
response = await async_client.get("/api/v1/users")
assert response.status_code == 200
assert isinstance(response.json(), list)
```
#### Overriding dependencies for tests
```python
from app.dependencies import get_db, get_current_user
from app.models.user import User
# Mock database session
async def override_get_db():
async with test_session_maker() as session:
yield session
# Mock authenticated user
async def override_get_current_user():
return User(id=1, email="test@example.com", name="Test")
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
# In conftest.py — clean up after tests
@pytest.fixture(autouse=True)
def clear_overrides():
yield
app.dependency_overrides.clear()
```
#### Testing WebSocket endpoints
```python
def test_websocket():
with client.websocket_connect("/ws/test-room") as ws:
ws.send_text("hello")
data = ws.receive_text()
assert "hello" in data
```
---
## Best Practices
1. Use Pydantic models for request/response validation
2. Organize routes with APIRouter
3. Use dependency injection for services
4. Return proper HTTP status codes
5. Add OpenAPI descriptions
1. **Use Pydantic models for all request/response validation** — never pass raw dicts through your API boundary. Define separate `Create`, `Update`, and `Response` schemas for each resource.
2. **Organize routes with APIRouter** — group related endpoints by resource and version. Apply shared dependencies at the router level, not on each individual route.
3. **Separate business logic from routes** — route functions should only handle HTTP concerns (parsing request, returning response). Delegate logic to service classes injected via `Depends()`.
4. **Use the lifespan context manager** — replace deprecated `on_event("startup")` and `on_event("shutdown")` with the `lifespan` async context manager for resource setup and teardown.
5. **Return proper HTTP status codes** — 201 for creation, 204 for deletion, 202 for accepted-but-not-done, 409 for conflicts. Use `status_code` parameter on route decorators.
6. **Add OpenAPI metadata** — provide `summary`, `description`, `tags`, and `responses` on routes. Set `title`, `version`, and `description` on the FastAPI app. This generates high-quality auto-docs.
7. **Use async all the way down** — if your route is `async def`, every I/O call inside it must also be async. Mixing sync blocking calls (e.g., `requests.get()`) in an async route will block the event loop.
8. **Configure settings with pydantic-settings** — load config from environment variables with validation and type coercion. Never hardcode secrets or connection strings.
```python
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str
API_KEY: str
DEBUG: bool = False
model_config = {"env_file": ".env"}
settings = Settings()
```
---
## Common Pitfalls
- **Blocking I/O in async**: Use async libraries
- **Missing response models**: Always define them
- **No error handling**: Use HTTPException properly
1. **Blocking I/O in async routes** — calling `requests.get()`, `time.sleep()`, or synchronous DB drivers inside `async def` routes starves the event loop. Use `httpx`, `asyncio.sleep()`, and async database drivers instead. If you must call sync code, use `run_in_executor`.
2. **Missing response_model** — without `response_model`, FastAPI returns whatever you return, potentially leaking internal fields (passwords, internal IDs). Always define a Pydantic response schema.
3. **Forgetting to await coroutines** — calling `await db.execute(query)` vs `db.execute(query)` is easy to miss. The latter returns a coroutine object instead of results. Enable linting rules that catch unawaited coroutines.
4. **Circular imports between models and schemas** — when schemas reference ORM models and vice versa, you get import cycles. Fix by using `TYPE_CHECKING` imports or by keeping schemas and models in separate modules that do not import each other.
5. **Not handling Pydantic validation errors** — FastAPI returns 422 by default, but the error format may confuse API consumers. Add a custom exception handler to reshape validation error responses to match your API's error format.
6. **Sharing mutable state across requests without locks** — global mutable variables (lists, dicts) accessed from async routes can cause race conditions. Use async-safe structures or dependency-injected per-request state.
---
## Related Skills
- `languages/python` — Python language patterns and best practices
- `api/openapi` — OpenAPI specification and documentation standards
- `databases/postgresql` — Database integration with async SQLAlchemy
- `testing/pytest` — Testing FastAPI applications with pytest and httpx
- `patterns/authentication` — JWT, OAuth2, and session patterns for FastAPI endpoints
- `patterns/logging` — Structured logging for FastAPI applications
@@ -0,0 +1,229 @@
# FastAPI Project Structure Reference
## Small Project (1-5 endpoints, single module)
```
project/
├── main.py # App factory, routes, startup
├── models.py # Pydantic schemas + SQLAlchemy models
├── database.py # DB connection, session factory
├── config.py # Settings via pydantic-settings
├── requirements.txt
├── .env
└── tests/
├── conftest.py # Fixtures (test client, test DB)
└── test_main.py
```
**When to use**: Prototypes, microservices, internal tools, single-domain APIs.
**`main.py` structure**:
```python
from fastapi import FastAPI
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
yield
# shutdown
app = FastAPI(lifespan=lifespan)
@app.get("/health")
async def health(): return {"status": "ok"}
```
---
## Medium Project (5-20 endpoints, feature-grouped)
```
project/
├── app/
│ ├── __init__.py
│ ├── main.py # App factory, include routers
│ ├── config.py # Settings (pydantic-settings)
│ ├── database.py # Engine, SessionLocal, Base
│ ├── dependencies.py # Shared deps (get_db, get_current_user)
│ ├── exceptions.py # Custom exception handlers
│ ├── middleware.py # CORS, logging, timing middleware
│ │
│ ├── auth/
│ │ ├── __init__.py
│ │ ├── router.py # POST /login, POST /register
│ │ ├── schemas.py # LoginRequest, TokenResponse
│ │ ├── models.py # User SQLAlchemy model
│ │ ├── service.py # Business logic (hash, verify, tokens)
│ │ └── dependencies.py # get_current_user, require_role
│ │
│ ├── items/
│ │ ├── __init__.py
│ │ ├── router.py # CRUD endpoints
│ │ ├── schemas.py # ItemCreate, ItemRead, ItemUpdate
│ │ ├── models.py # Item SQLAlchemy model
│ │ └── service.py # Business logic
│ │
│ └── shared/
│ ├── __init__.py
│ ├── pagination.py # Pagination params + response schema
│ └── filters.py # Common query filter patterns
├── alembic/ # DB migrations
│ ├── env.py
│ └── versions/
├── alembic.ini
├── requirements.txt
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
└── tests/
├── conftest.py
├── auth/
│ └── test_router.py
└── items/
├── test_router.py
└── test_service.py
```
**When to use**: Multi-feature APIs, team projects, typical SaaS backends.
**Key patterns**:
- Each feature gets its own directory with router, schemas, models, service
- `router.py` uses `APIRouter(prefix="/items", tags=["items"])`
- `main.py` includes routers: `app.include_router(items.router)`
- Shared deps in root `dependencies.py`, feature-specific in feature dir
---
## Large Project (20+ endpoints, domain-driven)
```
project/
├── src/
│ ├── __init__.py
│ ├── main.py # App factory only
│ ├── config.py # Layered settings
│ │
│ ├── core/ # Framework-level concerns
│ │ ├── __init__.py
│ │ ├── database.py # Engine, session management
│ │ ├── security.py # JWT, hashing, RBAC
│ │ ├── exceptions.py # Base exceptions + handlers
│ │ ├── middleware.py # All middleware stack
│ │ ├── dependencies.py # Cross-cutting deps
│ │ ├── events.py # Domain event bus
│ │ └── pagination.py # Cursor + offset pagination
│ │
│ ├── domain/ # Business logic (framework-agnostic)
│ │ ├── users/
│ │ │ ├── __init__.py
│ │ │ ├── entity.py # Domain entity (plain dataclass)
│ │ │ ├── repository.py # Abstract repository interface
│ │ │ ├── service.py # Business rules
│ │ │ └── events.py # Domain events
│ │ ├── orders/
│ │ │ └── ...
│ │ └── payments/
│ │ └── ...
│ │
│ ├── infrastructure/ # External system adapters
│ │ ├── database/
│ │ │ ├── models.py # All SQLAlchemy models
│ │ │ ├── repositories/ # Concrete repo implementations
│ │ │ │ ├── user_repo.py
│ │ │ │ └── order_repo.py
│ │ │ └── migrations/ # Alembic
│ │ ├── cache/
│ │ │ └── redis_client.py
│ │ ├── email/
│ │ │ └── smtp_service.py
│ │ └── external/
│ │ └── stripe_client.py
│ │
│ └── api/ # HTTP layer only
│ ├── __init__.py
│ ├── v1/
│ │ ├── __init__.py # v1 router aggregator
│ │ ├── users.py # Thin: parse request -> call service -> format response
│ │ ├── orders.py
│ │ └── payments.py
│ ├── v2/
│ │ └── ...
│ ├── schemas/ # Request/response schemas
│ │ ├── user_schemas.py
│ │ ├── order_schemas.py
│ │ └── common.py
│ ├── dependencies.py # API-layer deps
│ └── websockets/
│ └── notifications.py
├── tests/
│ ├── conftest.py
│ ├── unit/
│ │ ├── domain/
│ │ │ └── test_user_service.py
│ │ └── ...
│ ├── integration/
│ │ ├── test_user_api.py
│ │ └── test_order_flow.py
│ └── e2e/
│ └── test_checkout.py
├── scripts/ # Dev/ops scripts
│ ├── seed_db.py
│ └── migrate.py
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
└── Makefile
```
**When to use**: Complex domains, multiple teams, long-lived products.
**Key patterns**:
- **Domain layer** has zero framework imports (testable in isolation)
- **Infrastructure** implements domain interfaces (repository pattern)
- **API layer** is thin: validation, auth, call service, return schema
- API versioning via `/api/v1/`, `/api/v2/`
- Separate unit, integration, and e2e test directories
---
## File Responsibilities
| File | Responsibility | Dependencies |
|------|---------------|-------------|
| `router.py` | HTTP handling, request parsing, response formatting | schemas, service, dependencies |
| `schemas.py` | Pydantic models for request/response validation | None (or shared schemas) |
| `models.py` | SQLAlchemy/ODM models (DB table mapping) | database |
| `service.py` | Business logic, orchestration | repository/models, external services |
| `dependencies.py` | FastAPI `Depends()` callables | database, config, auth |
| `exceptions.py` | Custom exceptions + handlers | None |
| `config.py` | `BaseSettings` with env loading | None |
## Router Registration Pattern
```python
# app/main.py
from fastapi import FastAPI
from app.auth.router import router as auth_router
from app.items.router import router as items_router
def create_app() -> FastAPI:
app = FastAPI(title="My API")
app.include_router(auth_router)
app.include_router(items_router)
return app
app = create_app()
```
```python
# app/items/router.py
from fastapi import APIRouter, Depends
router = APIRouter(prefix="/items", tags=["items"])
@router.get("/")
async def list_items(db=Depends(get_db)): ...
```
+639 -59
View File
@@ -1,112 +1,692 @@
---
name: nextjs
description: >
Use this skill when working with Next.js applications, App Router, Server Components, or Server Actions. Trigger for any mention of Next.js, next/server, next/navigation, route handlers, SSR, SSG, ISR, middleware, or the app/ directory structure. Also applies when building full-stack React applications with API routes, implementing streaming or suspense boundaries, or configuring next.config.
---
# Next.js
## Description
Next.js framework with App Router, Server Components, and full-stack development patterns.
## When to Use
- React applications with SSR/SSG
- Full-stack applications
- App Router patterns
- SEO-critical sites needing server rendering
## When NOT to Use
- Pure React SPAs without SSR needs — use the `frameworks/react` skill instead
- Non-React frameworks (Vue, Svelte, Angular) — this skill is React/Next.js specific
- Backend-only projects without a frontend — consider `frameworks/fastapi` or `frameworks/django`
---
## Core Patterns
### App Router Structure
### 1. App Router
#### Directory structure
```
app/
├── layout.tsx # Root layout
├── page.tsx # Home page
├── loading.tsx # Loading UI
├── error.tsx # Error UI
├── layout.tsx # Root layout (wraps entire app)
├── page.tsx # Home page (/)
├── loading.tsx # Root loading UI (Suspense fallback)
├── error.tsx # Root error boundary
├── not-found.tsx # Custom 404 page
├── global-error.tsx # Error boundary for root layout itself
├── favicon.ico
├── globals.css
├── api/
── users/
└── route.ts # API route
└── users/
── page.tsx # Users page
└── [id]/
└── page.tsx # User detail
── users/
└── route.ts # GET/POST /api/users
│ │ └── [id]/
│ │ ── route.ts # GET/PUT/DELETE /api/users/:id
└── webhooks/
└── stripe/
│ └── route.ts # POST /api/webhooks/stripe
├── (marketing)/ # Route group (no URL segment)
│ ├── layout.tsx # Layout for marketing pages only
│ ├── page.tsx # / (same as root, can override)
│ ├── about/
│ │ └── page.tsx # /about
│ └── pricing/
│ └── page.tsx # /pricing
├── (app)/ # Route group for authenticated app
│ ├── layout.tsx # App shell layout (sidebar, nav)
│ ├── dashboard/
│ │ ├── page.tsx # /dashboard
│ │ ├── loading.tsx # Loading skeleton for dashboard
│ │ └── error.tsx # Error boundary for dashboard
│ ├── projects/
│ │ ├── page.tsx # /projects
│ │ └── [id]/
│ │ ├── page.tsx # /projects/:id
│ │ ├── edit/
│ │ │ └── page.tsx # /projects/:id/edit
│ │ └── layout.tsx # Shared layout for project detail
│ └── settings/
│ └── page.tsx # /settings
└── @modal/ # Parallel route slot
└── (.)projects/
└── [id]/
└── page.tsx # Intercepted route modal
```
### Server Components
#### Special files and their roles
| File | Purpose | Renders when |
|------|---------|-------------|
| `page.tsx` | Route UI | URL matches segment |
| `layout.tsx` | Shared wrapper, preserved across navigation | Always for child routes |
| `loading.tsx` | Suspense fallback | While page/data is loading |
| `error.tsx` | Error boundary | When child throws |
| `not-found.tsx` | 404 UI | When `notFound()` is called |
| `route.ts` | API endpoint | HTTP request to segment |
| `template.tsx` | Like layout but re-mounts on navigation | Every navigation |
| `default.tsx` | Fallback for parallel routes | When slot has no match |
```tsx
// app/users/page.tsx - Server Component (default)
async function UsersPage() {
const users = await db.users.findMany();
// app/layout.tsx — Root layout (required)
import type { Metadata } from "next";
export const metadata: Metadata = {
title: { default: "My App", template: "%s | My App" },
description: "Application description",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<nav>{/* Global navigation */}</nav>
<main>{children}</main>
</body>
</html>
);
}
// app/error.tsx — Error boundary (must be client component)
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>Page not found</h2>
<p>The requested resource does not exist.</p>
</div>
);
}
```
### 2. Server vs Client Components
#### Decision guide
| Use Server Component when | Use Client Component when |
|---------------------------|--------------------------|
| Fetching data | Using useState, useEffect, useRef |
| Accessing backend resources directly | Adding event handlers (onClick, onChange) |
| Keeping sensitive data on server | Using browser APIs (localStorage, window) |
| Reducing client bundle size | Using third-party client libraries |
| SEO-critical content | Animations, real-time updates |
#### Composition patterns
```tsx
// Server Component (default — no directive needed)
// app/projects/page.tsx
import { ProjectList } from "./project-list";
import { SearchBar } from "./search-bar"; // Client component
export default async function ProjectsPage() {
const projects = await db.project.findMany({
orderBy: { createdAt: "desc" },
});
return (
<div>
<h1>Projects</h1>
{/* Client component receives server data as props */}
<SearchBar />
{/* Server component can render client children */}
<ProjectList projects={projects} />
</div>
);
}
// Client Component — must have "use client" at top
// app/projects/search-bar.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
export function SearchBar() {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set("q", term);
} else {
params.delete("q");
}
startTransition(() => {
router.replace(`/projects?${params.toString()}`);
});
}
return (
<input
type="search"
placeholder="Search projects..."
defaultValue={searchParams.get("q") ?? ""}
onChange={(e) => handleSearch(e.target.value)}
className={isPending ? "opacity-50" : ""}
/>
);
}
```
**Key rule:** The `"use client"` directive creates a boundary. Everything imported into a client component becomes part of the client bundle. Pass server data down as serializable props (no functions, no classes).
### 3. Data Fetching
#### Server component fetch with caching
```tsx
// Fetch with automatic deduplication and caching
async function getProjects() {
const res = await fetch("https://api.example.com/projects", {
next: { revalidate: 60 }, // Revalidate every 60 seconds (ISR)
// next: { tags: ["projects"] }, // Tag-based revalidation
// cache: "no-store", // Always fresh (SSR)
// cache: "force-cache", // Cache indefinitely (SSG)
});
if (!res.ok) throw new Error("Failed to fetch projects");
return res.json();
}
export default async function ProjectsPage() {
const projects = await getProjects();
return <ProjectList projects={projects} />;
}
```
#### generateStaticParams for static generation
```tsx
// app/projects/[id]/page.tsx
export async function generateStaticParams() {
const projects = await db.project.findMany({ select: { id: true } });
return projects.map((p) => ({ id: String(p.id) }));
}
export default async function ProjectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const project = await db.project.findUnique({ where: { id } });
if (!project) notFound();
return <ProjectDetail project={project} />;
}
```
#### Route handlers (API routes)
```typescript
// app/api/projects/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = Number(searchParams.get("page") ?? "1");
const limit = Number(searchParams.get("limit") ?? "20");
const projects = await db.project.findMany({
skip: (page - 1) * limit,
take: limit,
});
return NextResponse.json({ data: projects, page, limit });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const project = await db.project.create({ data: body });
return NextResponse.json(project, { status: 201 });
}
// Dynamic route: app/api/projects/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const project = await db.project.findUnique({ where: { id } });
if (!project) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(project);
}
```
### 4. Server Actions
#### Form actions
```tsx
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const ProjectSchema = z.object({
title: z.string().min(3).max(200),
description: z.string().optional(),
});
export async function createProject(prevState: unknown, formData: FormData) {
const parsed = ProjectSchema.safeParse({
title: formData.get("title"),
description: formData.get("description"),
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const project = await db.project.create({ data: parsed.data });
revalidatePath("/projects");
redirect(`/projects/${project.id}`);
}
export async function deleteProject(id: string) {
await db.project.delete({ where: { id } });
revalidatePath("/projects");
}
```
#### Using actions in client components with useActionState
```tsx
"use client";
import { useActionState } from "react";
import { createProject } from "../actions";
export function CreateProjectForm() {
const [state, formAction, isPending] = useActionState(createProject, null);
return (
<form action={formAction}>
<input name="title" placeholder="Project title" required />
{state?.errors?.title && (
<p className="text-red-500">{state.errors.title[0]}</p>
)}
<textarea name="description" placeholder="Description" />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Project"}
</button>
</form>
);
}
```
#### Optimistic updates
```tsx
"use client";
import { useOptimistic } from "react";
import { deleteProject } from "../actions";
export function ProjectList({ projects }: { projects: Project[] }) {
const [optimisticProjects, removeOptimistic] = useOptimistic(
projects,
(state, removedId: string) => state.filter((p) => p.id !== removedId),
);
async function handleDelete(id: string) {
removeOptimistic(id);
await deleteProject(id);
}
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
{optimisticProjects.map((project) => (
<li key={project.id}>
{project.title}
<button onClick={() => handleDelete(project.id)}>Delete</button>
</li>
))}
</ul>
);
}
```
### Client Components
### 5. Middleware
```typescript
// middleware.ts (root of project, NOT inside app/)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Auth check
const token = request.cookies.get("session")?.value;
if (pathname.startsWith("/dashboard") && !token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Add headers
const response = NextResponse.next();
response.headers.set("x-pathname", pathname);
// Geo-based redirect
const country = request.geo?.country;
if (pathname === "/" && country === "DE") {
return NextResponse.redirect(new URL("/de", request.url));
}
// Rewrite (URL stays same, content changes)
if (pathname.startsWith("/old-path")) {
return NextResponse.rewrite(new URL("/new-path", request.url));
}
return response;
}
// Matcher: only run middleware on specific paths
export const config = {
matcher: [
// Match all paths except static files and api routes
"/((?!_next/static|_next/image|favicon.ico|api).*)",
// Or match specific paths
// "/dashboard/:path*",
// "/projects/:path*",
],
};
```
### 6. Caching
#### Cache layers overview
| Layer | What it caches | Control |
|-------|---------------|---------|
| Request Memoization | `fetch()` calls with same URL during single render | Automatic, per-request |
| Data Cache | `fetch()` results across requests | `next: { revalidate }`, `cache` option |
| Full Route Cache | HTML and RSC payload of static routes | `export const dynamic = "force-dynamic"` |
| Router Cache | Client-side RSC payload | `router.refresh()`, time-based |
#### Revalidation strategies
```tsx
'use client';
// Time-based revalidation (ISR)
fetch(url, { next: { revalidate: 3600 } }); // 1 hour
import { useState } from 'react';
// On-demand revalidation by path
import { revalidatePath } from "next/cache";
revalidatePath("/projects"); // Revalidate specific page
revalidatePath("/projects", "layout"); // Revalidate layout and all pages under it
export function Counter() {
const [count, setCount] = useState(0);
// On-demand revalidation by tag
import { revalidateTag } from "next/cache";
// When fetching:
fetch(url, { next: { tags: ["projects"] } });
// When invalidating:
revalidateTag("projects");
// Route segment config
export const dynamic = "force-dynamic"; // Never cache (SSR)
export const revalidate = 60; // ISR with 60s interval
export const fetchCache = "default-cache";
```
#### unstable_cache for non-fetch data
```tsx
import { unstable_cache } from "next/cache";
const getCachedProjects = unstable_cache(
async (orgId: string) => {
return db.project.findMany({ where: { organizationId: orgId } });
},
["projects"], // Cache key parts
{ revalidate: 60, tags: ["projects"] },
);
export default async function ProjectsPage() {
const projects = await getCachedProjects("org-123");
return <ProjectList projects={projects} />;
}
```
### 7. Route Groups & Parallel Routes
#### Route groups with `(groupName)`
Route groups organize routes without affecting the URL:
```
app/
├── (marketing)/ # URL: / , /about, /pricing (no "marketing" in URL)
│ ├── layout.tsx # Marketing layout (hero, footer)
│ ├── page.tsx
│ └── about/page.tsx
├── (app)/ # URL: /dashboard, /projects
│ ├── layout.tsx # App layout (sidebar, auth)
│ └── dashboard/page.tsx
```
#### Parallel routes with `@slotName`
```
app/
├── layout.tsx
├── page.tsx
├── @analytics/
│ ├── page.tsx # Rendered in parallel
│ └── default.tsx # Fallback when no match
├── @sidebar/
│ ├── page.tsx
│ └── default.tsx
```
```tsx
// app/layout.tsx — receives parallel route slots as props
export default function Layout({
children,
analytics,
sidebar,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
sidebar: React.ReactNode;
}) {
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
<div className="flex">
<aside>{sidebar}</aside>
<main>{children}</main>
<aside>{analytics}</aside>
</div>
);
}
```
### API Routes
#### Intercepting routes
```typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET() {
const users = await db.users.findMany();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const data = await request.json();
const user = await db.users.create({ data });
return NextResponse.json(user, { status: 201 });
}
```
app/
├── projects/
│ ├── page.tsx # /projects — full list
│ └── [id]/
│ └── page.tsx # /projects/:id — full page
├── @modal/
│ ├── (.)projects/
│ │ └── [id]/
│ │ └── page.tsx # Intercepts /projects/:id as modal
│ └── default.tsx # No modal by default
```
### Server Actions
Convention: `(.)` = same level, `(..)` = one level up, `(...)` = root.
### 8. Image & Font Optimization
#### next/image
```tsx
// app/actions.ts
'use server';
import Image from "next/image";
export async function createUser(formData: FormData) {
const name = formData.get('name') as string;
await db.users.create({ data: { name } });
revalidatePath('/users');
// Local image (automatically gets width/height)
import heroImage from "@/public/hero.png";
export function Hero() {
return (
<Image
src={heroImage}
alt="Hero banner"
placeholder="blur" // Auto blur placeholder for local images
priority // Preload for LCP images
className="w-full h-auto"
/>
);
}
// Remote image (must specify dimensions)
export function Avatar({ url, name }: { url: string; name: string }) {
return (
<Image
src={url}
alt={name}
width={48}
height={48}
className="rounded-full"
/>
);
}
// next.config.ts — allow remote image domains
const config = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "avatars.githubusercontent.com" },
{ protocol: "https", hostname: "**.cloudinary.com" },
],
},
};
```
#### next/font
```tsx
// app/layout.tsx
import { Inter, JetBrains_Mono } from "next/font/google";
import localFont from "next/font/local";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const mono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
const customFont = localFont({
src: "./fonts/CustomFont.woff2",
variable: "--font-custom",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
```
```css
/* In Tailwind config or globals.css */
:root {
--font-sans: var(--font-inter);
--font-mono: var(--font-mono);
}
```
---
## Best Practices
1. Use Server Components by default
2. Add 'use client' only when needed
3. Colocate data fetching with components
4. Use loading.tsx for suspense boundaries
5. Implement proper error boundaries
1. **Default to Server Components** — only add `"use client"` when you need interactivity, event handlers, or browser APIs. Server Components reduce bundle size and allow direct data access.
2. **Colocate data fetching with the component that uses it** — fetch inside the Server Component that renders the data, not in a parent that passes it down. Next.js deduplicates identical `fetch()` calls automatically.
3. **Use `loading.tsx` for instant loading states** — every route segment can have a `loading.tsx` that wraps the page in a Suspense boundary. This gives users immediate feedback during navigation.
4. **Validate Server Action inputs** — Server Actions are public HTTP endpoints. Always validate with zod or similar. Never trust `formData` values without parsing and validating.
5. **Use route groups to share layouts without affecting URLs**`(marketing)` and `(app)` let you have completely different layouts (public vs authenticated) without nesting URL segments.
6. **Prefer `revalidatePath`/`revalidateTag` over `cache: "no-store"`** — on-demand revalidation gives you fresh data when it changes while still serving cached content for performance. Only use `"no-store"` for truly dynamic per-request data.
7. **Put middleware at the project root**`middleware.ts` must be at the same level as `app/`, not inside it. Use the `matcher` config to limit which paths it runs on for performance.
8. **Use `next/image` for all images** — it handles lazy loading, responsive sizes, format conversion (WebP/AVIF), and blur placeholders. Set `priority` on above-the-fold LCP images. Configure `remotePatterns` for external image sources.
---
## Common Pitfalls
- **Using hooks in Server Components**: Mark as 'use client'
- **Large client bundles**: Keep client components small
- **Missing loading states**: Add loading.tsx files
1. **Using hooks in Server Components**`useState`, `useEffect`, `useRouter` (from `next/navigation`) only work in Client Components. If you see "hooks can only be called inside a function component," add `"use client"` or restructure to push interactivity to a child component.
2. **Passing non-serializable props across the server/client boundary** — functions, class instances, and Dates cannot be passed from Server to Client Components. Serialize data to plain objects and strings before passing as props.
3. **Large client bundles from misplaced `"use client"`** — placing the directive too high in the tree pulls entire subtrees into the client bundle. Push `"use client"` as deep as possible, wrapping only the interactive leaf components.
4. **Stale data from aggressive caching** — the Full Route Cache and Data Cache can serve stale content. Use `revalidatePath()`/`revalidateTag()` in Server Actions and route handlers after mutations. Call `router.refresh()` on the client if needed.
5. **Missing `default.tsx` for parallel routes** — when navigating to a URL that does not match a parallel route slot, Next.js renders `default.tsx`. Without it, you get a 404. Always provide a default for every `@slot`.
6. **Forgetting `loading.tsx` leads to blank pages during navigation** — without loading boundaries, users see nothing while Server Components fetch data. Add `loading.tsx` at every route segment that does async work.
---
## Related Skills
- `frameworks/react` — React component patterns, hooks, and state management
- `languages/typescript` — TypeScript strict mode and type patterns
- `frontend/tailwind` — Styling with Tailwind CSS
- `frontend/shadcn-ui` — UI component library built on Radix and Tailwind
- `patterns/authentication` — Protected routes and auth middleware for Next.js
- `patterns/caching` — Next.js caching layers and invalidation
- `patterns/state-management` — React state management in Next.js apps
@@ -0,0 +1,237 @@
# Next.js Patterns Quick Reference
> App Router (Next.js 13.4+). Pages Router patterns not covered.
## App Router File Conventions
| File | Purpose | Renders |
|------|---------|---------|
| `page.tsx` | Route UI (makes route publicly accessible) | Server Component |
| `layout.tsx` | Shared UI wrapping children (preserved on nav) | Server Component |
| `template.tsx` | Like layout but re-mounts on navigation | Server Component |
| `loading.tsx` | Instant loading UI (Suspense boundary) | Server Component |
| `error.tsx` | Error boundary for segment | **Client Component** |
| `not-found.tsx` | UI for `notFound()` calls | Server Component |
| `route.ts` | API endpoint (GET, POST, etc.) | N/A |
| `default.tsx` | Fallback for parallel routes | Server Component |
| `middleware.ts` | Runs before requests (root only) | Edge Runtime |
| `opengraph-image.tsx` | Dynamic OG image generation | Edge Runtime |
### Route Segment Folders
| Pattern | Example | Purpose |
|---------|---------|---------|
| Static | `app/about/page.tsx` | `/about` |
| Dynamic | `app/blog/[slug]/page.tsx` | `/blog/hello-world` |
| Catch-all | `app/docs/[...slug]/page.tsx` | `/docs/a/b/c` |
| Optional catch-all | `app/docs/[[...slug]]/page.tsx` | `/docs` or `/docs/a/b` |
| Route group | `app/(marketing)/about/page.tsx` | Groups without URL segment |
| Parallel route | `app/@modal/login/page.tsx` | Simultaneous route slots |
| Intercepted route | `app/(.)photo/[id]/page.tsx` | Intercept navigation |
---
## Caching Layers Summary
| Layer | What | Where | Duration | Opt-out |
|-------|------|-------|----------|---------|
| Request Memoization | `fetch()` dedup in single render | Server | Per request | `AbortController` |
| Data Cache | `fetch()` results | Server | Persistent | `cache: 'no-store'` or `revalidate: 0` |
| Full Route Cache | Static HTML + RSC payload | Server | Persistent | Dynamic functions or `revalidate` |
| Router Cache | RSC payload | Client | Session (30s dynamic, 5min static) | `router.refresh()` |
### Revalidation Strategies
```typescript
// Time-based
fetch(url, { next: { revalidate: 60 } }); // Revalidate every 60s
// On-demand (from API route or Server Action)
import { revalidatePath, revalidateTag } from "next/cache";
revalidatePath("/blog"); // Revalidate path
revalidateTag("posts"); // Revalidate by tag
// Tag a fetch for on-demand revalidation
fetch(url, { next: { tags: ["posts"] } });
// Opt out entirely
fetch(url, { cache: "no-store" });
// Route segment config
export const dynamic = "force-dynamic"; // Always dynamic
export const revalidate = 60; // Segment-level ISR
```
---
## Data Fetching Patterns
### Server Component (default, preferred)
```typescript
// Async Server Component - fetch directly
async function BlogPage() {
const posts = await fetch("https://api.example.com/posts", {
next: { revalidate: 3600 }
}).then(r => r.json());
return <PostList posts={posts} />;
}
```
### Parallel Data Fetching
```typescript
async function Dashboard() {
// Start all fetches simultaneously
const [user, orders, stats] = await Promise.all([
getUser(),
getOrders(),
getStats(),
]);
return <DashboardView user={user} orders={orders} stats={stats} />;
}
```
### Streaming with Suspense
```typescript
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
{/* Shows instantly */}
<StaticContent />
{/* Streams in when ready */}
<Suspense fallback={<Skeleton />}>
<SlowDataComponent />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<AnotherSlowComponent />
</Suspense>
</div>
);
}
```
### Cached Server Functions
```typescript
import { cache } from "react";
// Deduplicated across components in same request
export const getUser = cache(async (id: string) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
});
```
---
## Server Action Patterns
### Basic Form Action
```typescript
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
await db.post.create({ data: { title, body } });
revalidatePath("/posts");
redirect("/posts");
}
```
```typescript
// In a Server Component
import { createPost } from "./actions";
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" />
<textarea name="body" />
<button type="submit">Create</button>
</form>
);
}
```
### Optimistic Updates
```typescript
"use client";
import { useOptimistic } from "react";
function TodoList({ todos, addTodo }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newTodo: string) => [...state, { text: newTodo, pending: true }]
);
return (
<form action={async (formData) => {
const text = formData.get("text") as string;
addOptimistic(text);
await addTodo(text);
}}>
{optimisticTodos.map(todo => (
<div style={{ opacity: todo.pending ? 0.5 : 1 }}>{todo.text}</div>
))}
<input name="text" />
</form>
);
}
```
---
## Middleware Patterns
```typescript
// middleware.ts (project root)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Auth check
const token = request.cookies.get("token")?.value;
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Add headers
const response = NextResponse.next();
response.headers.set("x-request-id", crypto.randomUUID());
return response;
}
// Only run on matching paths
export const config = {
matcher: [
// Match all except static files and api
"/((?!_next/static|_next/image|favicon.ico|api/).*)",
],
};
```
### Common Middleware Uses
| Use Case | Pattern |
|----------|---------|
| Auth redirect | Check cookie/header, redirect to login |
| i18n routing | Read Accept-Language, redirect to locale |
| A/B testing | Set cookie, rewrite to variant |
| Rate limiting | Check IP/token bucket, return 429 |
| Bot protection | Check User-Agent, block or challenge |
| Feature flags | Read flag, rewrite to feature variant |
+679 -72
View File
@@ -1,108 +1,715 @@
---
name: react
description: >
Use this skill when building React components, using React hooks, or managing component state. Trigger for any mention of React, JSX, TSX, useState, useEffect, useCallback, useMemo, useContext, custom hooks, or React component patterns. Also applies when implementing context providers, handling component lifecycle, optimizing re-renders, or structuring React application architecture.
---
# React
## Description
React component patterns, hooks, and state management best practices.
## When to Use
- Building React components
- Using React hooks
- Component state management
- Client-side interactivity in any React-based framework
## When NOT to Use
- Vue, Svelte, or Angular projects — this skill is React-specific
- Backend-only projects without a frontend UI layer
- Static HTML pages that do not require a JavaScript framework
---
## Core Patterns
### Functional Components
### 1. Hooks
#### When-to-use guide
| Hook | Use when you need | Do NOT use for |
|------|-------------------|----------------|
| `useState` | Simple local state (toggle, form input, counter) | Derived/computed values |
| `useEffect` | Side effects: subscriptions, DOM mutations, timers | Data transformation (use useMemo) |
| `useRef` | Mutable value that persists across renders without triggering re-render; DOM refs | State that should cause re-render |
| `useMemo` | Expensive computation that should only rerun when deps change | Simple/cheap calculations |
| `useCallback` | Stable function reference to prevent child re-renders | Every function (only when needed) |
| `useReducer` | Complex state with multiple sub-values or state transitions | Simple boolean/string state |
| `useContext` | Reading context values | Frequently changing global state (causes re-renders) |
#### useState
```tsx
interface UserCardProps {
user: User;
onSelect?: (user: User) => void;
// Simple state
const [count, setCount] = useState(0);
const [user, setUser] = useState<User | null>(null);
// Functional updates (when new state depends on previous)
setCount((prev) => prev + 1);
// Lazy initialization (expensive initial value)
const [data, setData] = useState(() => computeExpensiveDefault());
```
#### useEffect
```tsx
// Run on mount + cleanup on unmount
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal).then(setData);
return () => controller.abort(); // Cleanup
}, []); // Empty deps = run once
// Run when dependency changes
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []); // No deps needed — handler is stable
// Sync external system with state
useEffect(() => {
document.title = `${count} items`;
}, [count]);
```
#### useRef
```tsx
// DOM reference
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => inputRef.current?.focus();
// Mutable value (no re-render on change)
const renderCount = useRef(0);
useEffect(() => {
renderCount.current += 1;
});
// Previous value pattern
const prevValueRef = useRef(value);
useEffect(() => {
prevValueRef.current = value;
}, [value]);
```
#### useReducer
```tsx
interface State {
items: Item[];
loading: boolean;
error: string | null;
}
export function UserCard({ user, onSelect }: UserCardProps) {
type Action =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: Item[] }
| { type: "FETCH_ERROR"; error: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH_START":
return { ...state, loading: true, error: null };
case "FETCH_SUCCESS":
return { items: action.payload, loading: false, error: null };
case "FETCH_ERROR":
return { ...state, loading: false, error: action.error };
}
}
const [state, dispatch] = useReducer(reducer, {
items: [],
loading: false,
error: null,
});
// Dispatch actions
dispatch({ type: "FETCH_START" });
```
### 2. Custom Hooks
#### Extraction pattern
Extract a custom hook when:
- Two or more components share the same stateful logic
- A component's hook logic is complex enough to deserve its own name and tests
- You want to abstract away an external API (localStorage, WebSocket, etc.)
**Rules:**
- Name must start with `use`
- Can call other hooks (unlike regular functions)
- Each call gets its own independent state
#### Practical examples
```tsx
// useLocalStorage — persist state to localStorage
function useLocalStorage<T>(key: string, initialValue: T) {
const [stored, setStored] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStored((prev) => {
const next = value instanceof Function ? value(prev) : value;
window.localStorage.setItem(key, JSON.stringify(next));
return next;
});
},
[key],
);
return [stored, setValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage("theme", "light");
```
```tsx
// useDebounce — debounce a rapidly changing value
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
fetchResults(debouncedSearch);
}, [debouncedSearch]);
```
```tsx
// useFetch — generic data fetching hook
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => setData(json as T))
.catch((err) => {
if (err.name !== "AbortError") setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, error, loading };
}
// Usage
const { data: users, loading, error } = useFetch<User[]>("/api/users");
```
### 3. Component Patterns
#### Compound components
```tsx
// Components that work together, sharing implicit state
interface TabsContextType {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextType | null>(null);
function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<div className="card" onClick={() => onSelect?.(user)}>
<h3>{user.name}</h3>
<p>{user.email}</p>
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div role="tablist">{children}</div>
</TabsContext.Provider>
);
}
function TabTrigger({ value, children }: { value: string; children: ReactNode }) {
const ctx = useContext(TabsContext)!;
return (
<button
role="tab"
aria-selected={ctx.activeTab === value}
onClick={() => ctx.setActiveTab(value)}
>
{children}
</button>
);
}
function TabContent({ value, children }: { value: string; children: ReactNode }) {
const ctx = useContext(TabsContext)!;
if (ctx.activeTab !== value) return null;
return <div role="tabpanel">{children}</div>;
}
// Attach sub-components
Tabs.Trigger = TabTrigger;
Tabs.Content = TabContent;
// Usage
<Tabs defaultTab="settings">
<Tabs.Trigger value="profile">Profile</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
<Tabs.Content value="profile"><ProfileForm /></Tabs.Content>
<Tabs.Content value="settings"><SettingsForm /></Tabs.Content>
</Tabs>
```
#### Render props
```tsx
interface MousePosition {
x: number;
y: number;
}
function MouseTracker({ render }: { render: (pos: MousePosition) => ReactNode }) {
const [pos, setPos] = useState<MousePosition>({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
return <>{render(pos)}</>;
}
// Usage
<MouseTracker render={({ x, y }) => <span>Mouse: {x}, {y}</span>} />
```
#### Controlled vs uncontrolled
```tsx
// Controlled — parent owns the state
interface ControlledInputProps {
value: string;
onChange: (value: string) => void;
}
function ControlledInput({ value, onChange }: ControlledInputProps) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
// Uncontrolled — component owns the state, parent reads via ref or callback
function UncontrolledInput({ defaultValue }: { defaultValue?: string }) {
const ref = useRef<HTMLInputElement>(null);
return <input ref={ref} defaultValue={defaultValue} />;
}
// Flexible pattern — supports both controlled and uncontrolled
function FlexibleInput({
value: controlledValue,
defaultValue = "",
onChange,
}: {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}) {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!isControlled) setInternalValue(e.target.value);
onChange?.(e.target.value);
}
return <input value={value} onChange={handleChange} />;
}
```
### 4. Context
#### Provider pattern with separate state and dispatch
```tsx
// Split context to prevent unnecessary re-renders
interface AppState {
user: User | null;
theme: "light" | "dark";
}
type AppAction =
| { type: "SET_USER"; user: User | null }
| { type: "TOGGLE_THEME" };
const AppStateContext = createContext<AppState | null>(null);
const AppDispatchContext = createContext<React.Dispatch<AppAction> | null>(null);
function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case "SET_USER":
return { ...state, user: action.user };
case "TOGGLE_THEME":
return { ...state, theme: state.theme === "light" ? "dark" : "light" };
}
}
function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, {
user: null,
theme: "light",
});
return (
<AppStateContext.Provider value={state}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
);
}
// Typed hooks for consumers
function useAppState() {
const ctx = useContext(AppStateContext);
if (!ctx) throw new Error("useAppState must be used within AppProvider");
return ctx;
}
function useAppDispatch() {
const ctx = useContext(AppDispatchContext);
if (!ctx) throw new Error("useAppDispatch must be used within AppProvider");
return ctx;
}
```
**Why split?** Components that only dispatch actions (buttons) do not re-render when state changes. Only components that read state re-render.
#### Context splitting for performance
```tsx
// Instead of one giant context with everything:
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<"light" | "dark">("light");
const NotificationContext = createContext<Notification[]>([]);
// Components subscribe only to the context they need
function Avatar() {
const user = useContext(UserContext); // Only re-renders when user changes
return <img src={user?.avatar} />;
}
```
### 5. Error Boundaries
#### Class-based error boundary
```tsx
class ErrorBoundary extends React.Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: ReactNode; fallback: ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Error boundary caught:", error, info.componentStack);
// Send to error tracking service
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
```
#### react-error-boundary library (recommended)
```tsx
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert">
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
// Usage
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => {
// Reset app state if needed
}}
resetKeys={[userId]} // Auto-reset when these values change
>
<Dashboard />
</ErrorBoundary>
// Programmatic error throwing from child
function SaveButton() {
const { showBoundary } = useErrorBoundary();
async function handleSave() {
try {
await saveData();
} catch (error) {
showBoundary(error); // Propagate to nearest ErrorBoundary
}
}
return <button onClick={handleSave}>Save</button>;
}
```
### 6. Suspense
#### Suspense boundaries with lazy loading
```tsx
import { Suspense, lazy } from "react";
// Code-split heavy components
const HeavyChart = lazy(() => import("./heavy-chart"));
const AdminPanel = lazy(() => import("./admin-panel"));
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
<Suspense fallback={<div>Loading admin panel...</div>}>
<AdminPanel />
</Suspense>
</div>
);
}
```
### Hooks
#### Suspense with data fetching (React 19+ / framework integration)
```tsx
// useState
const [count, setCount] = useState(0);
// useEffect
useEffect(() => {
const subscription = subscribe();
return () => subscription.unsubscribe();
}, [dependency]);
// useMemo
const expensive = useMemo(() => compute(data), [data]);
// useCallback
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
```
### Custom Hooks
```tsx
function useUser(id: string) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetchUser(id)
.then(setUser)
.finally(() => setLoading(false));
}, [id]);
return { user, loading };
}
```
### Context Pattern
```tsx
const UserContext = createContext<User | null>(null);
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// With a Suspense-compatible data source (React Query, Next.js, Relay)
function ProjectList() {
return (
<UserContext.Provider value={user}>
{children}
</UserContext.Provider>
<Suspense fallback={<ProjectListSkeleton />}>
<ProjectListContent />
</Suspense>
);
}
export function useUser() {
const context = useContext(UserContext);
if (!context) throw new Error('useUser must be within UserProvider');
return context;
// The data-fetching component suspends while loading
function ProjectListContent() {
const { data } = useSuspenseQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
});
return (
<ul>
{data.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
```
#### Named exports with lazy
```tsx
// For named exports, wrap in a default export adapter
const UserSettings = lazy(() =>
import("./user-settings").then((mod) => ({ default: mod.UserSettings })),
);
```
### 7. Performance
#### React.memo
```tsx
// Only re-renders when props change (shallow comparison)
const ExpensiveList = React.memo(function ExpensiveList({
items,
onSelect,
}: {
items: Item[];
onSelect: (item: Item) => void;
}) {
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onSelect(item)}>
{item.name}
</li>
))}
</ul>
);
});
// Custom comparison
const Chart = React.memo(ChartComponent, (prev, next) => {
return prev.data.length === next.data.length && prev.title === next.title;
});
```
#### useMemo and useCallback together
```tsx
function ParentComponent({ items }: { items: Item[] }) {
// Memoize expensive derived data
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items],
);
// Stable function reference for memoized child
const handleSelect = useCallback((item: Item) => {
console.log("Selected:", item.id);
}, []);
// ExpensiveList only re-renders when sortedItems or handleSelect change
return <ExpensiveList items={sortedItems} onSelect={handleSelect} />;
}
```
#### Key prop optimization
```tsx
// BAD: using index as key — breaks state when list order changes
{items.map((item, index) => <Item key={index} data={item} />)}
// GOOD: stable unique key
{items.map((item) => <Item key={item.id} data={item} />)}
// Force remount: change key to reset component state
<ProfileForm key={userId} userId={userId} />
// When userId changes, the form unmounts and remounts with fresh state
```
#### Virtualization for large lists
```tsx
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated row height in px
overscan: 5, // Extra rows rendered above/below viewport
});
return (
<div ref={parentRef} style={{ height: "400px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
width: "100%",
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
}
```
---
## Best Practices
1. Keep components small and focused
2. Use TypeScript for props
3. Memoize expensive computations
4. Clean up effects properly
5. Lift state up when needed
1. **Keep components small and single-purpose** — if a component exceeds ~100 lines or handles multiple concerns, extract sub-components or custom hooks. Name components after what they render, not what they do.
2. **Use TypeScript interfaces for all props** — define explicit prop types. Avoid `any`. Use discriminated unions for props that change based on a variant. Export prop types for reuse.
3. **Clean up all effects** — return a cleanup function from every `useEffect` that subscribes to events, starts timers, or creates abort controllers. Missing cleanups cause memory leaks and stale state bugs.
4. **Derive state instead of syncing it** — if a value can be computed from props or other state, compute it during render (or with `useMemo`). Never `useEffect` to sync derived state — it causes an extra render.
5. **Lift state to the lowest common ancestor** — not higher. State should live in the closest parent that needs it. If siblings need shared state, lift to their parent. If distant components need it, use context.
6. **Use `useCallback` and `React.memo` together, not alone**`useCallback` only helps when the function is passed to a memoized child. `React.memo` only helps when the parent actually passes stable props. Using one without the other is wasted effort.
7. **Prefer composition over prop drilling** — instead of passing props through 5 levels, restructure so the parent renders the child directly (component composition) or use context for truly global state.
8. **Handle all async states** — every data-fetching component should handle loading, error, and empty states. Use Suspense and Error Boundaries for declarative handling. Never leave a component that shows nothing while loading.
---
## Common Pitfalls
- **Missing dependencies in hooks**: Include all dependencies
- **State updates on unmounted**: Use cleanup functions
- **Prop drilling**: Use context or composition
1. **Missing or wrong dependency arrays** — forgetting to add a dependency to `useEffect`/`useMemo`/`useCallback` causes stale closures. Adding too many causes unnecessary re-runs. Use the `react-hooks/exhaustive-deps` ESLint rule.
2. **Setting state during render** — calling `setState` unconditionally in the render body causes infinite re-render loops. State updates should be in event handlers, effects, or callbacks — never at the top level of the component function.
3. **Prop drilling through many layers** — passing a prop through 4+ intermediate components that do not use it. Fix with composition (restructuring the component tree), context (for shared state), or a state management library.
4. **Creating objects/arrays in JSX props**`<Child style={{ color: "red" }} />` creates a new object every render, defeating `React.memo`. Hoist constants outside the component or use `useMemo`.
5. **Using `useEffect` for derived state** — syncing state with `useEffect(() => setFullName(first + last), [first, last])` causes double renders. Just compute it: `const fullName = first + last`. Use `useMemo` if the computation is expensive.
6. **Not handling race conditions in effects** — when a component fetches data based on a prop, fast prop changes can cause older responses to arrive after newer ones, displaying stale data. Use `AbortController` or a boolean flag to ignore stale responses.
---
## Related Skills
- `frameworks/nextjs` — Next.js App Router, SSR, and full-stack React patterns
- `languages/typescript` — TypeScript strict mode and type patterns
- `frontend/tailwind` — Styling with Tailwind CSS
- `frontend/shadcn-ui` — UI component library built on Radix and Tailwind
- `testing/vitest` — Testing React components with vitest and testing-library
- `patterns/state-management` — State management patterns for React
@@ -0,0 +1,243 @@
# React Patterns Quick Reference
## Hook Rules
1. Only call hooks at the **top level** (not inside loops, conditions, or nested functions)
2. Only call hooks from **React function components** or **custom hooks**
3. Custom hooks **must** start with `use`
4. Hook call order must be **identical** on every render
```typescript
// BAD
if (isLoggedIn) {
const [user, setUser] = useState(null); // Conditional hook call
}
// GOOD
const [user, setUser] = useState(null);
// Use the state conditionally instead
```
---
## Custom Hook Recipes
### useLocalStorage
```typescript
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage("theme", "dark");
```
### useDebounce
```typescript
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
fetchResults(debouncedSearch);
}, [debouncedSearch]);
```
### useMediaQuery
```typescript
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
// Usage
const isMobile = useMediaQuery("(max-width: 768px)");
```
### usePrevious
```typescript
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current;
}
// Usage
const prevCount = usePrevious(count);
```
---
## Compound Component Pattern
```typescript
// Components that work together sharing implicit state
const Accordion = ({ children }: { children: React.ReactNode }) => {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<AccordionContext.Provider value={{ openIndex, setOpenIndex }}>
<div role="tablist">{children}</div>
</AccordionContext.Provider>
);
};
const AccordionItem = ({ index, title, children }: {
index: number; title: string; children: React.ReactNode;
}) => {
const { openIndex, setOpenIndex } = useContext(AccordionContext);
const isOpen = openIndex === index;
return (
<div>
<button onClick={() => setOpenIndex(isOpen ? null : index)}>
{title}
</button>
{isOpen && <div>{children}</div>}
</div>
);
};
Accordion.Item = AccordionItem;
// Usage - clean, declarative API
<Accordion>
<Accordion.Item index={0} title="Section 1">Content 1</Accordion.Item>
<Accordion.Item index={1} title="Section 2">Content 2</Accordion.Item>
</Accordion>
```
---
## Context Patterns
### Split State and Dispatch
```typescript
// Prevent unnecessary re-renders by splitting contexts
const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<Dispatch | null>(null);
function Provider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<DispatchContext.Provider value={dispatch}>
<StateContext.Provider value={state}>
{children}
</StateContext.Provider>
</DispatchContext.Provider>
);
}
// Components that only dispatch won't re-render on state changes
function useAppDispatch() {
const ctx = useContext(DispatchContext);
if (!ctx) throw new Error("Must be used within Provider");
return ctx;
}
```
---
## Performance Checklist
### When to Memoize
| Situation | Solution |
|-----------|----------|
| Expensive computation | `useMemo(() => compute(data), [data])` |
| Stable callback for child | `useCallback(fn, [deps])` |
| Prevent child re-render | `React.memo(Component)` |
| Stable object/array prop | `useMemo(() => ({ ... }), [deps])` |
### When NOT to Memoize
- Simple/cheap calculations
- Primitives (strings, numbers, booleans) -- already stable
- Functions only used in the same component
- Components that are cheap to render
### Key Optimizations
```typescript
// 1. Move state down (colocate state with where it's used)
// BAD: parent re-renders everything
function Parent() {
const [hover, setHover] = useState(false);
return <><ExpensiveChild /><HoverTarget onHover={setHover} /></>;
}
// GOOD: isolate the stateful part
function Parent() {
return <><ExpensiveChild /><HoverSection /></>;
}
function HoverSection() {
const [hover, setHover] = useState(false);
return <HoverTarget onHover={setHover} />;
}
// 2. Pass children to avoid re-rendering them
function ScrollTracker({ children }: { children: React.ReactNode }) {
const [scroll, setScroll] = useState(0);
// children are created by parent, not re-created on scroll change
return <div onScroll={e => setScroll(e.currentTarget.scrollTop)}>{children}</div>;
}
// 3. Use key to reset component state
<Form key={selectedId} initialData={data} />
// 4. Lazy load heavy components
const HeavyChart = lazy(() => import("./HeavyChart"));
<Suspense fallback={<Skeleton />}>
<HeavyChart data={data} />
</Suspense>
```
### React DevTools Profiler Checklist
1. Enable "Highlight updates" to spot unnecessary re-renders
2. Record a profiler session and look for:
- Components re-rendering without visible changes
- Long render times (>16ms blocks frames)
- Cascading re-renders from context changes
3. Fix with: state colocation, memo, context splitting, or external state