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.