mirror of
https://github.com/duthaho/claudekit.git
synced 2026-07-25 06:01:01 +03:00
feat: adding new skills, including testing patterns and methodologies, along with bundled resources for better usability.
This commit is contained in:
@@ -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"]
|
||||
```
|
||||
@@ -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)): ...
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user