RSS
 

Posts Tagged ‘zod’

Data Transfer Objects and NextJS Server Actions

01 Aug

Next.js server actions can be leveraged to implement the DTO (Data Transfer Object) design pattern to structure and manage data flow between the client and server.

What Are Data Transfer Objects (DTO)s?

DTOs are objects that define the structure of data being transferred between layers of an application (e.g., client and server). They are typically used to:

  1. Encapsulate Data: Define a clear contract for the shape of data being sent or received.
  2. Validate Data: Ensure that the data conforms to a specific structure.
  3. Decouple Layers: Abstract the internal structure of the server or database from the client.

DTOs with Next.js Server Actions

Server actions in Next.js provide a way to handle server-side logic directly in the application. By using DTOs as interfaces for server actions, you can:

  1. Standardize Data Contracts
    • Define clear and reusable interfaces for the data sent to and received from server actions.
    • Ensure consistency across the application.
  2. Improve Type Safety
    • Use TypeScript to enforce the structure of data at compile time.
    • Reduce runtime errors caused by unexpected data shapes.
  3. Simplify Validation
    • Validate incoming and outgoing data against the DTO structure.
    • Use libraries like zod or class-validator for runtime validation.
  4. Enhance Maintainability:
    • Centralize data definitions, making it easier to update and refactor the application.

How to Implement DTOs with Server Actions in Next.js

Step 1: Define DTO Interfaces

Create TypeScript interfaces or types to define the structure of the data being transferred.

import { z } from "zod";

// Define the DTO schema using zod for runtime validation
export const SheetUserWeightsDTO = z.object({
  userId: z.string(),
  sheetId: z.string(),
  specWeights: z.record(z.string(), z.number()), // Example: { spec1: 0.5, spec2: 0.8 }
  specsVersion: z.number(),
});

// Infer the TypeScript type from the zod schema
export type SheetUserWeightsDTOType = z.infer<typeof SheetUserWeightsDTO>;

Step 2: Use DTOs in Server Actions

Leverage the DTOs in server actions to validate and enforce the structure of incoming and outgoing data.

import { SheetUserWeightsDTO, SheetUserWeightsDTOType } from "@/lib/dto/sheetUserWeights.dto";

export async function saveSheetUserWeightsAction(data: SheetUserWeightsDTOType): Promise<void> {
  // Validate the incoming data
  const parsedData = SheetUserWeightsDTO.parse(data);

  // Perform the server-side logic (e.g., save to database)
  const { userId, sheetId, specWeights, specsVersion } = parsedData;

  // Example: Save to database
  await db.collection("sheetUserWeights").updateOne(
    { userId, sheetId },
    { $set: { specWeights, specsVersion } },
    { upsert: true }
  );
}

export async function getSheetUserWeightsAction(userId: string, sheetId: string): Promise<SheetUserWeightsDTOType | null> {
  // Fetch data from the database
  const result = await db.collection("sheetUserWeights").findOne({ userId, sheetId });

  if (!result) return null;

  // Validate the fetched data
  return SheetUserWeightsDTO.parse(result);
}

Step 3: Use DTOs in Client-Side Hooks

Use the DTOs in React Query hooks to ensure type safety and consistency.

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SheetUserWeightsDTOType } from "@/lib/dto/sheetUserWeights.dto";
import { getSheetUserWeightsAction, saveSheetUserWeightsAction } from "@/lib/actions/sheets";

export function useQuerySheetUserWeights(userId: string, sheetId: string) {
  return useQuery<SheetUserWeightsDTOType | null, Error>({
    queryKey: ["sheetUserWeights", userId, sheetId],
    queryFn: () => getSheetUserWeightsAction(userId, sheetId),
    enabled: !!userId && !!sheetId,
    staleTime: 1000 * 60 * 5, // Cache for 5 minutes
  });
}

export function useSaveSheetUserWeights() {
  const queryClient = useQueryClient();

  return useMutation<void, Error, SheetUserWeightsDTOType>({
    mutationFn: saveSheetUserWeightsAction,
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries(["sheetUserWeights", variables.userId, variables.sheetId]);
    },
  });
}

Benefits of This Approach

  1. Type Safety
    • Both the client and server share the same DTO definitions, ensuring consistent data structures.
  2. Validation:
    • DTOs can validate incoming and outgoing data at runtime, catching errors early.
  3. Reusability:
    • DTOs can be reused across server actions, client-side hooks, and even database queries.
  4. Scalability:
    • As the application grows, DTOs provide a clear and maintainable way to manage data contracts.

When to Use DTOs

  • Complex Applications:
    • DTOs are especially useful in applications with complex data flows or multiple layers (e.g., client, server, database).
  • Shared Data Contracts:
    • When the same data structure is used across multiple parts of the application (e.g., client and server).
  • Validation Requirements:
    • When you need to validate data at runtime to ensure correctness.

Conclusion

Using DTOs as interfaces for server actions in Next.js is a powerful design pattern that improves type safety, validation, and maintainability. By defining DTOs with tools like zod or TypeScript interfaces, you can standardize data contracts across your application and ensure consistency between the client and server.

This article was generated mostly via my prompting of AI CoPilot; Image was generated via Claude.ai. I had to edit the result.