Skip to content

Blog Tutorial: Advanced Forms

In this chapter, we'll add edit and delete functionality, handle multiple actions, and implement optimistic UI.

Note on approach: This tutorial continues using createLoader and createAction with the shorthand form. For routes needing caching or validation, you can switch to the options object form. See Data Loading for details.

Edit Post Page

Create app/routes/posts/[slug]/edit.tsx:

tsx
// app/routes/posts/[slug]/edit.tsx
import { createLoader, createAction, redirect } from '@ereo/data'
import { Form, Link, useActionData, useNavigation } from '@ereo/client'
import { getPost, db } from '~/lib/db'

export const loader = createLoader(async ({ params }) => {
  const post = getPost(params.slug)

  if (!post) {
    throw new Response('Post not found', { status: 404 })
  }

  return { post }
})

export const action = createAction(async ({ request, params }) => {
  const formData = await request.formData()
  const intent = formData.get('intent')

  const post = getPost(params.slug)
  if (!post) {
    throw new Response('Post not found', { status: 404 })
  }

  // Handle delete
  if (intent === 'delete') {
    db.prepare('DELETE FROM posts WHERE id = ?').run(post.id)
    return redirect('/posts')
  }

  // Handle update
  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 } }
  }

  // Update the post
  db.prepare(`
    UPDATE posts
    SET title = ?, content = ?, excerpt = ?, updated_at = CURRENT_TIMESTAMP
    WHERE id = ?
  `).run(title.trim(), content.trim(), excerpt?.trim() || '', post.id)

  return redirect(`/posts/${params.slug}`)
})

export default function EditPost({ loaderData }: { loaderData: any }) {
  const { post } = loaderData
  const actionData = useActionData()
  const navigation = useNavigation()
  const isSubmitting = navigation.status === 'submitting'

  // Check which action is being performed
  const isDeleting =
    isSubmitting && navigation.formData?.get('intent') === 'delete'
  const isSaving =
    isSubmitting && navigation.formData?.get('intent') === 'save'

  return (
    <div>
      <h1>Edit Post</h1>

      <Form method="post" className="post-form">
        <div className="form-group">
          <label htmlFor="title">Title</label>
          <input
            type="text"
            id="title"
            name="title"
            defaultValue={actionData?.values?.title ?? post.title}
            disabled={isSubmitting}
            required
          />
          {actionData?.errors?.title && (
            <span className="error">{actionData.errors.title}</span>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="excerpt">Excerpt</label>
          <input
            type="text"
            id="excerpt"
            name="excerpt"
            defaultValue={actionData?.values?.excerpt ?? post.excerpt}
            disabled={isSubmitting}
          />
        </div>

        <div className="form-group">
          <label htmlFor="content">Content</label>
          <textarea
            id="content"
            name="content"
            rows={10}
            defaultValue={actionData?.values?.content ?? post.content}
            disabled={isSubmitting}
            required
          />
          {actionData?.errors?.content && (
            <span className="error">{actionData.errors.content}</span>
          )}
        </div>

        <div className="form-actions">
          <button
            type="submit"
            name="intent"
            value="save"
            className="btn"
            disabled={isSubmitting}
          >
            {isSaving ? 'Saving...' : 'Save Changes'}
          </button>

          <Link href={`/posts/${post.slug}`} className="btn btn-secondary">
            Cancel
          </Link>
        </div>
      </Form>

      {/* Separate delete form */}
      <div className="danger-zone">
        <h3>Danger Zone</h3>
        <p>Deleting a post cannot be undone.</p>

        <Form method="post">
          <button
            type="submit"
            name="intent"
            value="delete"
            className="btn btn-danger"
            disabled={isSubmitting}
            onClick={(e) => {
              if (!confirm('Are you sure you want to delete this post?')) {
                e.preventDefault()
              }
            }}
          >
            {isDeleting ? 'Deleting...' : 'Delete Post'}
          </button>
        </Form>
      </div>
    </div>
  )
}

Add Edit Link to Post Page

Update app/routes/posts/[slug].tsx to add an edit link:

tsx
// Add after the post header in the PostPage component

<header>
  <h1>{post.title}</h1>
  <p className="post-meta">
    Published on {new Date(post.created_at).toLocaleDateString()}
    {' · '}
    <Link href={`/posts/${post.slug}/edit`}>Edit</Link>
  </p>
</header>

Add Styles

css
/* Add to public/styles.css */

.form-actions {
  display: flex;
  gap: 1rem;
  margin-top: 1.5rem;
}

.danger-zone {
  margin-top: 3rem;
  padding: 1.5rem;
  border: 1px solid #fecaca;
  border-radius: 0.5rem;
  background: #fef2f2;
}

.danger-zone h3 {
  color: #dc2626;
  margin-bottom: 0.5rem;
}

.danger-zone p {
  color: #7f1d1d;
  margin-bottom: 1rem;
}

.btn-danger {
  background: #dc2626;
}

.btn-danger:hover {
  background: #b91c1c;
}

Inline Actions with useFetcher

Let's add a "like" feature using useFetcher for non-navigating updates.

First, add a likes column to the database. Update app/lib/db.ts:

ts
// Add this after the CREATE TABLE statements
try {
  db.exec(`ALTER TABLE posts ADD COLUMN likes INTEGER DEFAULT 0`)
} catch {
  // Column might already exist
}

Create an API route for likes at app/routes/api/posts/[id]/like.ts:

ts
// app/routes/api/posts/[id]/like.ts
import { createAction } from '@ereo/data'
import { db } from '~/lib/db'

export const action = createAction(async ({ params }) => {
  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(params.id) as any

  if (!post) {
    return Response.json({ error: 'Post not found' }, { status: 404 })
  }

  db.prepare('UPDATE posts SET likes = likes + 1 WHERE id = ?').run(params.id)

  const updated = db.prepare('SELECT likes FROM posts WHERE id = ?').get(params.id) as any

  return Response.json({ likes: updated.likes })
})

Now create a LikeButton component at app/components/LikeButton.tsx:

tsx
// app/components/LikeButton.tsx
'use client';

import { createIsland, useFetcher } from '@ereo/client'

interface LikeButtonProps {
  postId: number
  initialLikes: number
}

function LikeButton({ postId, initialLikes }: LikeButtonProps) {
  const fetcher = useFetcher<{ likes: number }>()

  // Optimistic update: show +1 immediately while submitting
  const likes = fetcher.state === 'submitting'
    ? initialLikes + 1
    : (fetcher.data?.likes ?? initialLikes)

  const isLiking = fetcher.state === 'submitting'

  return (
    <fetcher.Form method="post" action={`/api/posts/${postId}/like`}>
      <button
        type="submit"
        className="like-button"
        disabled={isLiking}
      >
        ❤️ {likes} {likes === 1 ? 'Like' : 'Likes'}
      </button>
    </fetcher.Form>
  )
}

// Wrap with createIsland for selective hydration
export default createIsland(LikeButton, 'LikeButton')

Why createIsland? The useFetcher hook requires client-side JavaScript to intercept form submissions. Wrapping the component with createIsland() marks it as an island — only this component gets hydrated on the client, keeping the rest of the page as static HTML. See Islands Architecture for details.

Use it in the post page with the client:load directive:

tsx
// In app/routes/posts/[slug].tsx
import LikeButton from '~/components/LikeButton'

// In the component, after the title
<LikeButton client:load postId={post.id} initialLikes={post.likes || 0} />

Add styles:

css
/* Add to public/styles.css */

.like-button {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 1rem;
  background: #fef2f2;
  border: 1px solid #fecaca;
  border-radius: 9999px;
  color: #dc2626;
  cursor: pointer;
  font-size: 0.875rem;
  transition: all 0.2s;
}

.like-button:hover {
  background: #fee2e2;
}

.like-button:disabled {
  opacity: 0.7;
  cursor: wait;
}

Understanding Optimistic UI

The LikeButton demonstrates optimistic UI:

  1. User clicks "Like"
  2. Immediately show likes + 1 (optimistic)
  3. Send request to server in background
  4. When response arrives, use actual value
  5. If error, revert to original value
Click → Optimistic Update → Server Request → Confirm/Revert
         (instant UI)         (background)    (real data)

This makes the app feel instant even with slow networks.

What We've Done

  1. Created an edit page with multiple actions (save/delete)
  2. Used the intent pattern for handling multiple forms
  3. Added confirmation for destructive actions
  4. Implemented optimistic UI with useFetcher
  5. Created an interactive client component

Next Step

In the next chapter, we'll add proper styling with Tailwind CSS.

← Previous: Data Loading | Continue to Chapter 5: Styling →

Released under the MIT License.