My go-to patterns for full-stack/frontend projects
Source: Dev.to
Patterns I Reach for on Almost Every Project
After working on quite a few frontend and full‑stack projects (mostly React + TypeScript + some flavour of server/backend), I kept coming back to the same handful of patterns. They bring structure, reduce mental overhead, and make the codebase feel maintainable even as it grows.
These aren’t revolutionary, but they’re pragmatic choices that have worked well across different apps. Below is the current set I reach for almost every time.
1. TanStack Query (React Query) – Query‑Key Factory
Why: Keeps query keys consistent, readable, and refactor‑friendly.
Factory (single source of truth):
// lib/query-keys.ts
export const bookingKeys = {
all: ['bookings'] as const,
detail: (id: string) => [...bookingKeys.all, id] as const,
upcoming: (filters: { patientId?: string; page: number }) => [
...bookingKeys.all,
'upcoming',
filters,
] as const,
};
Usage in components:
useQuery({
queryKey: bookingKeys.detail(bookingId),
queryFn: () => getBooking(bookingId),
});
Centralised invalidations:
// Same file or a companion invalidations.ts
export const invalidateOnBookingChange = (queryClient: QueryClient) => {
// Invalidate everything related to bookings
queryClient.invalidateQueries({ queryKey: bookingKeys.all });
// Or more granular:
// queryClient.invalidateQueries({ queryKey: bookingKeys.upcoming(...) });
};
Having invalidations in one place lets you track and manage data freshness across dashboards, list pages, detail pages, etc., without hunting through components or mutations. One change ripples everywhere consistently.
2. Server‑Side Actions / Functions Instead of Traditional API Routes
What I use:
- Next.js Server Actions
- Astro Actions
- TanStack Start server functions
These are still essentially API‑like endpoints under the hood—they can be called directly (via fetch or form POST). Therefore you must still protect them with:
- Authentication
- Rate limiting
- CSRF tokens (where applicable)
- Input validation
Big wins:
| Benefit | Explanation |
|---|---|
| Less boilerplate | Direct function calls from client → no manual endpoint definitions |
| Automatic type safety | Types flow between client and server |
| Easier error handling & revalidation | Errors surface where the call is made |
| Colocated logic | form → action → DB → response live together |
| Better React integration | Works nicely with Suspense and transitions |
It’s not magic; it just removes ceremony while keeping security responsibilities intact.
3. Fine‑Grained Permissions with CASL
Define abilities once (usually per user/session):
import { AbilityBuilder, createMongoAbility } from '@casl/ability';
export const defineAbilitiesFor = (user: User | null) => {
const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
if (user?.role === 'admin') {
can('manage', 'all');
} else if (user) {
can('read', 'Booking', { patientId: user.id });
can('create', 'Booking');
can('update', 'Booking', { patientId: user.id });
cannot('delete', 'Booking'); // explicit deny example
}
return build();
};
Use in services:
class BookingService {
static async updateBooking(
user: User,
bookingId: string,
data: Partial<any>
) {
const ability = defineAbilitiesFor(user);
const booking = await getBookingDetails(bookingId); // from queries
if (!ability.can('update', booking)) {
throw new Error('Not authorized to update this booking');
}
// Proceed with update…
await updateBooking(bookingId, data);
}
}
Or inline checks:
if (ability.can('read', subject('Booking', { ownerId: user.id }))) {
// show sensitive data
}
Keeping permission logic declarative, testable, and out of business‑flow code makes it far easier to audit and evolve.
4. Thin Data‑Access Layer (queries/ folder)
Structure: Plain async functions that are pure DB statements—nothing else.
// queries/bookings.ts
export async function getBookingDetails(id: string): Promise<any> {
// Drizzle/Prisma/etc. query only
return db.select().from(bookings).where(eq(bookings.id, id)).limit(1);
}
export async function updateBooking(
id: string,
data: Partial<any>
): Promise<void> {
// Pure update, no side effects
await db.update(bookings).set(data).where(eq(bookings.id, id));
}
Strict rules for these functions:
- Only data access (
SELECT,INSERT,UPDATE,DELETE) - No business logic
- No authorisation checks
- No emails, queues, external calls, or side effects
- Reusable from any service
This thin DAL makes swapping ORMs trivial (change only the queries/ files) and keeps services focused on orchestration.
5. SSR/SSG – Hydrate with initialData
Pattern:
useQuery({
queryKey: bookingKeys.upcoming({ page: 1 }),
queryFn: () => actions.bookings.getUpcomingBookings({ page: 1 }),
initialData: page === 1 ? initialUpcoming : undefined,
});
Why it matters:
- SSR is non‑negotiable today. The era of plain CRA SPAs is over.
- React officially deprecated CRA for new apps in early 2025 and recommends frameworks (Next.js, TanStack Start, Astro, etc.) that have SSR/SSG built in.
- Leveraging server‑fetched data improves perceived performance, reduces layout shifts, and gives users something meaningful on first paint.
6. Classic Separation: Presentational vs. Container Components
| Type | Characteristics |
|---|---|
| Presentational (dumb) | Receives only props, no hooks/state/fetching. Pure UI, very easy to unit test and reason about. |
| Container (smart) | Handles data, state, orchestration, and passes props down. |
Example – Presentational component:
// Presentational – great for snapshot/visual testing
function BookingListView({
bookings,
isLoading,
page,
totalPages,
onPageChange,
}) {
if (isLoading) return null;
return (
<>
{/* Render bookings */}
{bookings.map((b) => (
<div key={b.id}>{/* booking UI */}</div>
))}
{/* Pagination controls */}
</>
);
}
The corresponding container component would fetch data, manage pagination state, and render <BookingListView …/>.
TL;DR
- Query‑key factories → single source of truth for TanStack Query.
- Server actions → replace boilerplate API routes, keep type safety.
- CASL → declarative, centralised permission handling.
- Thin DAL (
queries/) → pure DB functions, easy ORM swaps. - SSR/SSG +
initialData→ eliminate loading flashes, improve first‑paint experience. - Presentational vs. Container → clean separation, easier testing and maintenance.
These patterns have helped me keep codebases scalable, predictable, and pleasant to work on. Feel free to adopt any that fit your stack!
// BookingListView.tsx
function BookingListView({
bookings,
isLoading,
page,
totalPages,
onPageChange,
}) {
return (
<>
{isLoading ? null : bookings.map((b) => <div key={b.id}>{/* … */}</div>)}
</>
);
}
// Container
function BookingList() {
const {
bookings,
isLoading,
page,
setPage,
totalPages,
} = useBookings();
return <BookingListView
bookings={bookings}
isLoading={isLoading}
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>;
}
Rule of thumb:
Any time you see a component growing fat with state + data fetching + pagination + error handling → extract that logic into a custom hook.
Before vs. After
Before: 50+ lines of useQuery / useState / session logic inside the component.
After: The component only renders; all the heavy lifting lives in a hook.
// PatientDashboard.tsx
function PatientDashboard({
initialUpcoming,
initialPast,
initialNext,
}) {
const {
upcoming,
past,
nextAppointment,
isLoadingUpcoming,
upcomingPage,
setUpcomingPage,
// …
} = useDashboard({
initialUpcoming,
initialPast,
initialNext,
});
return (
<>
{/* Render dashboard UI */}
</>
);
}
Extraction Guideline
If you see
useState,useEffect,useQuery(or similar) clustered together for one clear purpose → extract to a custom hook.
Components stay focused on rendering.
Provider‑agnostic Services
When you might switch providers (Zoom → Google Meet → others), hide the implementation behind a unified interface.
// services/meeting.ts
class MeetingService {
static async createMeeting(input: CreateMeetingInput) {
// strategy selected by config / env
return activeMeetingProvider.create(input);
}
}
Keeps services clean and future‑proof.
Benefits of These Patterns
- Readable, well‑organised code
- Fewer weird logic bugs – everything has its place
- Lower maintenance cost – easier tests, fewer surprises
- Faster feature development – less time fighting structure
When everything follows clear conventions (documented in a single ARCHITECTURE.md or similar), AI tools like Cursor or Copilot become much more accurate. They “get” the patterns right away and generate code that actually fits, without you prompting them ten more times to put everything in the right folders, in the right format.
All the classic engineering benefits, without overcomplicating things.