Used

How toTransform FormData Between UI and Database in React Router

Imagine you're building a blog application where users can create and edit posts through forms. The form might submit data as FormData with fields like title, content, and tags, but your database expects a specific object structure with additional fields like authorId from the session. This transformation between UI and database formats is a common challenge that requires careful separation of concerns.

The key insight is understanding where these transformations belong in your application architecture: actions handle form-to-database transformations, while loaders handle database-to-form transformations when editing existing data.

Understanding the Three-Layer Architecture

React Router applications naturally follow a three-layer architecture that keeps concerns properly separated:

  • UI Layer: Your components that render forms and display data
  • HTTP Layer: Your actions and loaders that handle requests and responses
  • Data Layer: Your database functions that perform business operations

Each layer has specific responsibilities and shouldn't know about the others' implementation details.

Transform Form Data in Actions

Actions receive FormData from forms and must transform it into objects your database functions expect. This transformation includes parsing form fields, adding session data, and handling file uploads.

import { z } from "zod";
import { createPost } from "~/data/posts.server";

const CreatePostSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
  tags: z.string().optional(),
});

export async function action({ request, context }: Route.ActionArgs) {
  let formData = await request.formData();
  let authorId = context.get(sessionContext).userId;

  // Parse and validate form data
  let result = CreatePostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
    tags: formData.get("tags"),
  });

  if (!result.success) {
    return { errors: result.error.flatten() };
  }

  // Transform to database input format
  let createPostInput = {
    authorId,
    title: result.data.title,
    content: result.data.content,
    tags: result.data.tags ? result.data.tags.split(",") : [],
    publishedAt: new Date(),
  };

  let post = await createPost(createPostInput);
  return redirect(`/posts/${post.id}`);
}

The action transforms the FormData into a CreatePostInput object that includes session data (authorId) and business logic (publishedAt). The data layer function createPost receives a clean object without needing to know about forms or HTTP.

Transform Database Data in Loaders

Loaders retrieve data from the database and transform it into the shape your UI components need. This is especially important when prefilling forms for editing.

import { getPostById } from "~/data/posts.server";

export async function loader({ params, context }: Route.LoaderArgs) {
  let post = await getPostById(params.postId);
  let userId = context.get(sessionContext).userId;

  if (post.authorId !== userId) {
    throw new Response("Unauthorized", { status: 403 });
  }

  // Transform database data for form prefilling
  return {
    post: {
      title: post.title,
      content: post.content,
      tags: post.tags.join(","), // Convert array back to string for form
    },
  };
}

The loader transforms the database post object into a format that matches what the form expects. Arrays are converted to comma-separated strings, and only the fields needed by the UI are included.

Handle Complex Transformations

Real-world forms often involve more complex transformations, like handling file uploads alongside text data:

export async function action({ request, context }: Route.ActionArgs) {
  let formData = await request.formData();
  let authorId = context.get(sessionContext).userId;

  // Parse text fields
  let textFields = CreatePostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });

  if (!textFields.success) {
    return { errors: textFields.error.flatten() };
  }

  // Handle file uploads
  let imageFiles = z.file().array().parse(formData.getAll("images"));
  let imageUrls = await Promise.all(
    imageFiles.map((file) => {
      if (file.size === 0) return null;
      return uploadImage(file); // Assume this function uploads and returns a URL
    }),
  ).filter(Boolean);

  // Build complete DTO for data layer
  let createPostInput = {
    authorId,
    title: textFields.data.title,
    content: textFields.data.content,
    imageUrls,
    publishedAt: new Date(),
  };

  let post = await createPost(createPostInput);
  return redirect(`/posts/${post.id}`);
}

The action handles both form parsing and file processing before creating the final object. The data layer receives a complete DTO without knowing about FormData or file handling.

Create Action-Specific Schemas

Don't reuse database schemas directly in actions. Each action should have its own schema that matches what the form actually submits:

// Don't use the database schema directly
const UpdatePostSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
  tags: z.string().optional(),
  // Form doesn't include authorId or publishedAt
});

export async function action({ request, params, context }: Route.ActionArgs) {
  let formData = await request.formData();

  let result = UpdatePostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
    tags: formData.get("tags"),
  });

  if (!result.success) {
    return { errors: result.error.flatten() };
  }

  // Transform for data layer, preserving existing fields
  let updatePostInput = {
    title: result.data.title,
    content: result.data.content,
    tags: result.data.tags ? result.data.tags.split(",") : [],
    updatedAt: new Date(),
  };

  await updatePost(params.postId, updatePostInput);
  return redirect(`/posts/${params.postId}`);
}

This approach ensures your actions only validate what they actually receive, making them more maintainable and less coupled to database schemas.

Keep Data Layer Independent

Your data layer functions should only handle business logic and database operations, not form parsing or HTTP concerns:

interface CreatePostInput {
  authorId: string;
  title: string;
  content: string;
  tags: string[];
  imageUrls?: string[];
  publishedAt: Date;
}

export async function createPost(input: CreatePostInput) {
  // Business rule validation
  if (input.title.length > 100) {
    throw new Error("Title too long for SEO");
  }

  // Create post and related records
  return await db.transaction(async (tx) => {
    let post = await tx.posts.create({
      authorId: input.authorId,
      title: input.title,
      content: input.content,
      publishedAt: input.publishedAt,
    });

    if (input.tags.length > 0) {
      await tx.postTags.createMany(
        input.tags.map((tag) => ({ postId: post.id, tag })),
      );
    }

    return post;
  });
}

The createPost function receives a clean typed object and focuses on business rules and database operations. It can be reused from different HTTP endpoints or even CLI commands.

Final Thoughts

This separation of concerns makes your application more maintainable and testable. Actions and loaders act as translators between the HTTP world and your business logic, while keeping your data layer independent and reusable. The key is to remember that actions transform form data to database objects, loaders transform database data to UI-friendly formats, and the data layer remains focused on business operations.