DEV Community

Niki
Niki

Posted on • Originally published at sniki.dev

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

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. Here's the current set I reach for almost every time.

1. React Query + Query Key Factory pattern

I end up using TanStack Query (React Query) in nearly every project. To keep query keys consistent, readable, and refactor-friendly, I follow the query key factory approach.

Centralised factories make keys predictable and give great auto-completion:

// 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,
};

Enter fullscreen mode Exit fullscreen mode

Then in components:

useQuery({
  queryKey: bookingKeys.detail(bookingId),
  queryFn: () => getBooking(bookingId),
});

Enter fullscreen mode Exit fullscreen mode

The same factory file becomes the single source of truth for invalidations too. You can define an invalidation map and trigger queryClient.invalidateQueries() calls from one place:

// Same file or a companion invalidations.ts
export const invalidateOnBookingChange = (queryClient: QueryClient) => {
  queryClient.invalidateQueries({ queryKey: bookingKeys.all });
  // Or more granular:
  // queryClient.invalidateQueries({ queryKey: bookingKeys.upcoming(...) });
};

Enter fullscreen mode Exit fullscreen mode

Having invalidations centralised means you can easily track and manage data freshness across different parts of the app (dashboard, lists, details pages) without hunting through components or mutations. One change here ripples everywhere consistently.

2. Server Actions / Server Functions

I almost never write traditional API routes anymore. Instead, I use whatever flavour of server actions / functions the framework provides:

These are still essentially API-like endpoints under the hood—they can be called directly (e.g., via fetch or form POST), so you must protect them with authentication, rate limiting, CSRF tokens (where applicable), and input validation just like any API.

The big wins come from less boilerplate and more purpose-driven code:

  • Direct function calls from client → no manual endpoint definitions
  • Automatic type safety between client and server
  • Easier error handling and revalidation
  • Colocated logic (form → action → DB → response)
  • Better integration with React's Suspense and transitions

It's not magic, it just removes ceremony while keeping security responsibilities intact.

3. Permission / Authorisation Management with CASL

Most apps end up needing fine-grained permissions. I centralise that logic with CASL.

Define abilities once (often user/session-based):

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();
};

Enter fullscreen mode Exit fullscreen mode

Then use in services with simple conditionals:

class BookingService {
  static async updateBooking(user: User, bookingId: string, data: Partial<Booking>) {
    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);
  }
}

Enter fullscreen mode Exit fullscreen mode

Or inline:

if (ability.can('read', subject('Booking', { ownerId: user.id }))) {
  // show sensitive data
}

Enter fullscreen mode Exit fullscreen mode

Keeps permission logic declarative, testable, and out of business-flow code.

4. Lightweight Repository / Query pattern

I keep a queries/ folder with plain async functions that are pure DB statements and nothing else:

// queries/bookings.ts
export async function getBookingDetails(id: string): Promise<Booking | null> {
  // 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<Booking>): Promise<void> {
  // Pure update, no side effects
  await db.update(bookings).set(data).where(eq(bookings.id, id));
}

Enter fullscreen mode Exit fullscreen mode

Strict rules for these functions:

  • Only data access (i.e. SELECT, INSERT, UPDATE, DELETE)
  • No business logic
  • No authorisation checks
  • No emails, queues, external calls, or side effects
  • Reusable from any service

This thin Data Access Layer makes swapping ORMs trivial (change only queries/ files) and keeps services focused on orchestration.

5. Optimistic Initial Data in React Query

Pass SSR/SSG-fetched data as initialData to avoid loading flashes:

useQuery({
  queryKey: bookingKeys.upcoming({ page: 1 }),
  queryFn: () => actions.bookings.getUpcomingBookings({ page: 1 }),
  initialData: page === 1 ? initialUpcoming : undefined,
});

Enter fullscreen mode Exit fullscreen mode

SSR is non-negotiable today. The era of plain Create React App SPAs is over. Even the React team officially deprecated CRA for new apps in early 2025 and recommends frameworks instead. Modern file-based routing frameworks (Next.js, Tanstack Start, Astro, etc.) all have SSR/SSG built in. Leveraging that initial data improves perceived performance, reduces layout shifts, and gives users something meaningful on first paint. Why throw it away?

6. Container / Presentational aka Smart / Dumb Components pattern

I still like this classic separation:

  • Presentational (dumb): only props, no hooks/state/fetching → pure UI, very easy to unit test and reason about
  • Container (smart): handles data, state, orchestration, passes props down

Example:

// Presentational – great for snapshot/visual testing
function BookingListView({ bookings, isLoading, page, totalPages, onPageChange }) {
  if (isLoading) return <Skeleton />;
  return (
    <>
      <ul>{bookings.map(b => <BookingItem key={b.id} booking={b} />)}</ul>
      <Pagination page={page} total={totalPages} onChange={onPageChange} />
    </>
  );
}

// Container
function BookingList() {
  const { bookings, isLoading, page, setPage, totalPages } = useBookings();
  return <BookingListView {...{ bookings, isLoading, page, totalPages, onPageChange: setPage }} />;
}

Enter fullscreen mode Exit fullscreen mode

The dumb components become trivial to test in isolation, since it requires no mocking data layers or auth.

7. Custom Hook pattern

Any time you see a component growing fat with state + data fetching + pagination + error handling → extract it to a custom hook.

Before: 50+ lines of useQuery/useState/session logic inside the component.

After:

function PatientDashboard({ initialUpcoming, initialPast, initialNext }) {
  const {
    upcoming,
    past,
    nextAppointment,
    isLoadingUpcoming,
    upcomingPage,
    setUpcomingPage,
    // ...
  } = useDashboard({ initialUpcoming, initialPast, initialNext });

  return (
    <div className="space-y-8">
      <NextAppointmentCard booking={nextAppointment} isLoading={isLoadingNextAppointment} />
      <BookingList bookings={upcoming.data} page={upcomingPage} onPageChange={setUpcomingPage} />
      {/* ... */}
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

The rule: If you see useState, useEffect, useQuery (or similar) clustered together for one clear purpose → extract to a custom hook.

Components stay focused on rendering.

8. Strategy pattern (e.g. for third-party providers)

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Keeps services clean and future-proof.

Wrapping Up

These patterns show up in almost every project I work on now. Together they deliver:

  • 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)

And most importantly, when everything follows clear conventions (and you document them in a single ARCHITECTURE.md or similar) AI tools like Cursor or Copilot suddenly become much more accurate. They "get" the patterns right away and generate code that actually fits, without you prompting it 10 more times to put it all in the right folders, in the right format.

All the classic engineering benefits, without overcomplicating things.

Top comments (0)