My go-to patterns for full-stack/frontend projects

Published: (January 15, 2026 at 06:50 AM EST)
6 min read
Source: Dev.to

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:

BenefitExplanation
Less boilerplateDirect function calls from client → no manual endpoint definitions
Automatic type safetyTypes flow between client and server
Easier error handling & revalidationErrors surface where the call is made
Colocated logicform → action → DB → response live together
Better React integrationWorks 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

TypeCharacteristics
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.

Back to Blog

Related posts

Read more »

The tech stack behind InkRows

InkRows InkRowshttps://www.inkrows.com/ is a modern note‑taking app designed to work seamlessly across web and mobile platforms. Behind its clean, intuitive in...