Blog Tutorial: Data Loading
In this chapter, we'll add the ability to create new posts using actions and handle form submissions.
Note on approach: This tutorial uses
createLoaderandcreateActionwith the shorthand form (passing a function directly). This is one of several valid ways to define loaders and actions in EreoJS. See Data Loading for a comparison of all approaches.
Create Post Page
Create app/routes/posts/new.tsx:
tsx
// app/routes/posts/new.tsx
import { createAction, redirect } from '@ereo/data'
import { Form, useActionData, useNavigation } from '@ereo/client'
import { createPost } from '../../lib/db'
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
export const action = createAction(async ({ request }) => {
const formData = await request.formData()
const title = formData.get('title') as string
const content = formData.get('content') as string
const excerpt = formData.get('excerpt') as string
// Validation
const errors: Record<string, string> = {}
if (!title || title.trim().length < 3) {
errors.title = 'Title must be at least 3 characters'
}
if (!content || content.trim().length < 10) {
errors.content = 'Content must be at least 10 characters'
}
if (Object.keys(errors).length > 0) {
return {
errors,
values: { title, content, excerpt }
}
}
// Create the post
const slug = slugify(title)
try {
createPost({
title: title.trim(),
slug,
content: content.trim(),
excerpt: excerpt?.trim() || content.trim().slice(0, 160)
})
return redirect(`/posts/${slug}`)
} catch (error) {
return {
errors: { form: 'Failed to create post. The slug might already exist.' },
values: { title, content, excerpt }
}
}
})
export default function NewPost() {
const actionData = useActionData()
const navigation = useNavigation()
const isSubmitting = navigation.status === 'submitting'
return (
<div>
<h1>Write a New Post</h1>
<Form method="post" className="post-form">
{actionData?.errors?.form && (
<div className="error-banner">{actionData.errors.form}</div>
)}
<div className="form-group">
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
name="title"
defaultValue={actionData?.values?.title}
disabled={isSubmitting}
required
/>
{actionData?.errors?.title && (
<span className="error">{actionData.errors.title}</span>
)}
</div>
<div className="form-group">
<label htmlFor="excerpt">Excerpt (optional)</label>
<input
type="text"
id="excerpt"
name="excerpt"
placeholder="Brief description for post listings"
defaultValue={actionData?.values?.excerpt}
disabled={isSubmitting}
/>
</div>
<div className="form-group">
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
rows={10}
defaultValue={actionData?.values?.content}
disabled={isSubmitting}
required
/>
{actionData?.errors?.content && (
<span className="error">{actionData.errors.content}</span>
)}
</div>
<button type="submit" className="btn" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Post'}
</button>
</Form>
</div>
)
}Add Form Styles
Add to public/styles.css:
css
/* Form styles */
.post-form {
max-width: 600px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-group input:disabled,
.form-group textarea:disabled {
background: #f3f4f6;
cursor: not-allowed;
}
.form-group .error {
display: block;
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.error-banner {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}Add Comment Form
Update app/routes/posts/[slug].tsx to include a comment form:
tsx
// app/routes/posts/[slug].tsx
import { createLoader, createAction } from '@ereo/data'
import { Form, Link, useActionData, useNavigation } from '@ereo/client'
import { getPost, getPostComments, createComment } from '../../lib/db'
export const loader = createLoader(async ({ params }) => {
const post = getPost(params.slug)
if (!post) {
throw new Response('Post not found', { status: 404 })
}
const comments = getPostComments(post.id)
return { post, comments }
})
export const action = createAction(async ({ request, params }) => {
const formData = await request.formData()
const author = formData.get('author') as string
const content = formData.get('content') as string
// Validation
const errors: Record<string, string> = {}
if (!author || author.trim().length < 2) {
errors.author = 'Name must be at least 2 characters'
}
if (!content || content.trim().length < 3) {
errors.content = 'Comment must be at least 3 characters'
}
if (Object.keys(errors).length > 0) {
return { errors, values: { author, content } }
}
// Get the post to get its ID
const post = getPost(params.slug)
if (!post) {
return { errors: { form: 'Post not found' } }
}
createComment({
postId: post.id,
author: author.trim(),
content: content.trim()
})
return { success: true }
})
export function meta({ data }) {
return [
{ title: `${data.post.title} | My Blog` },
{ name: 'description', content: data.post.excerpt }
]
}
export default function PostPage({ loaderData }) {
const { post, comments } = loaderData
const actionData = useActionData()
const navigation = useNavigation()
const isSubmitting = navigation.status === 'submitting'
return (
<article>
<header>
<h1>{post.title}</h1>
<p className="post-meta">
Published on {new Date(post.created_at).toLocaleDateString()}
</p>
</header>
<div className="post-content">
{post.content.split('\n').map((paragraph: string, i: number) => (
<p key={i}>{paragraph}</p>
))}
</div>
<section className="comments">
<h2>Comments ({comments.length})</h2>
{/* Comment Form */}
<div className="comment-form">
<h3>Leave a Comment</h3>
{actionData?.success && (
<div className="success-banner">Comment posted!</div>
)}
<Form method="post">
<div className="form-group">
<label htmlFor="author">Name</label>
<input
type="text"
id="author"
name="author"
defaultValue={actionData?.success ? '' : actionData?.values?.author}
disabled={isSubmitting}
required
/>
{actionData?.errors?.author && (
<span className="error">{actionData.errors.author}</span>
)}
</div>
<div className="form-group">
<label htmlFor="content">Comment</label>
<textarea
id="content"
name="content"
rows={4}
defaultValue={actionData?.success ? '' : actionData?.values?.content}
disabled={isSubmitting}
required
/>
{actionData?.errors?.content && (
<span className="error">{actionData.errors.content}</span>
)}
</div>
<button type="submit" className="btn" disabled={isSubmitting}>
{isSubmitting ? 'Posting...' : 'Post Comment'}
</button>
</Form>
</div>
{/* Comments List */}
{comments.length === 0 ? (
<p>No comments yet. Be the first!</p>
) : (
<div className="comments-list">
{comments.map((comment: any) => (
<div key={comment.id} className="comment">
<strong>{comment.author}</strong>
<span className="comment-date">
{new Date(comment.created_at).toLocaleDateString()}
</span>
<p>{comment.content}</p>
</div>
))}
</div>
)}
</section>
<Link href="/posts">← Back to all posts</Link>
</article>
)
}Add Success Banner Style
css
/* Add to public/styles.css */
.success-banner {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
}
.comment-form {
background: #f9fafb;
padding: 1.5rem;
border-radius: 0.5rem;
margin-bottom: 2rem;
}
.comment-form h3 {
margin-bottom: 1rem;
}
.comments-list {
margin-top: 1.5rem;
}Understanding the Data Flow
Let's review what happens when a user submits a form:
- User submits form → Browser sends POST request
- Server receives request →
actionfunction runs - Action validates data → Returns errors or processes
- On success → Redirect to new page (or return success)
- On error → Return errors and form values
- Component re-renders → Shows errors or success message
User Input
│
▼
┌─────────────┐
│ Form Submit │
└─────────────┘
│
▼
┌─────────────┐
│ Action │ ← Validates, processes
└─────────────┘
│
├── Success → redirect('/posts/new-slug')
│
└── Error → { errors, values }
│
▼
┌─────────────┐
│ Re-render │ ← Shows errors
└─────────────┘Testing the Forms
Try these scenarios:
- Create a post with valid data → Should redirect to the new post
- Submit with empty fields → Should show validation errors
- Submit with short title → Should show "Title must be at least 3 characters"
- Add a comment → Should appear in the comments list
Progressive Enhancement
Notice that forms work even without JavaScript:
- Disable JavaScript in your browser
- Try submitting a form
- It still works! (Full page reload instead of SPA navigation)
When JavaScript is enabled, you get:
- No full page reload
- Loading states
- Optimistic UI updates (coming next)
What We've Done
- Created a form to add new posts
- Implemented server-side validation
- Handled form errors with helpful messages
- Added a comment form to post pages
- Learned about the action data flow
Next Step
In the next chapter, we'll add edit and delete functionality for posts.