Post Editor Form — Rich Text, Tags and File Upload

📋 Table of Contents
  1. Post Editor Form
  2. Common Mistakes

The post editor form is the most complex form in the blog application — it combines a title input, body textarea, slug auto-generation, a tag entry widget, a status select, and an optional file input for a cover image. Building it as a single component would create a 200-line monster; instead, we compose reusable form components (TagInput, ImageUpload) and handle both creating new posts (POST) and editing existing ones (PATCH with pre-filled values).

Post Editor Form

// src/pages/PostEditorPage.jsx
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { postsApi } from "@/services/posts";

function slugify(text) {
    return text
        .toLowerCase()
        .trim()
        .replace(/[^\w\s-]/g, "")
        .replace(/[\s_]+/g, "-")
        .replace(/--+/g, "-");
}

export default function PostEditorPage() {
    const { postId }              = useParams();
    const isEditing               = Boolean(postId);
    const navigate                = useNavigate();

    const [form, setForm] = useState({
        title:  "",
        body:   "",
        slug:   "",
        status: "draft",
        tags:   [],
    });
    const [errors, setErrors]     = useState({});
    const [isLoading, setLoading] = useState(false);
    const [isFetching, setFetching] = useState(isEditing);
    const [slugEdited, setSlugEdited] = useState(false);   // track manual slug edits

    // Load existing post when editing
    useEffect(() => {
        if (!isEditing) return;
        setFetching(true);
        postsApi.getById(postId)
            .then((post) => {
                setForm({
                    title:  post.title,
                    body:   post.body,
                    slug:   post.slug,
                    status: post.status,
                    tags:   post.tags.map((t) => t.name),
                });
                setSlugEdited(true);   // don't auto-overwrite existing slug
            })
            .finally(() => setFetching(false));
    }, [postId, isEditing]);

    // Auto-generate slug from title (unless manually edited)
    function handleTitleChange(e) {
        const title = e.target.value;
        setForm((prev) => ({
            ...prev,
            title,
            slug: slugEdited ? prev.slug : slugify(title),
        }));
        if (errors.title) setErrors((prev) => ({ ...prev, title: null }));
    }

    function handleChange(e) {
        const { name, value } = e.target;
        setForm((prev) => ({ ...prev, [name]: value }));
        if (errors[name]) setErrors((prev) => ({ ...prev, [name]: null }));
    }

    function validate() {
        const errs = {};
        if (!form.title.trim())          errs.title = "Title is required";
        if (!form.body.trim())           errs.body  = "Body is required";
        if (!form.slug.trim())           errs.slug  = "Slug is required";
        if (!/^[a-z0-9-]+$/.test(form.slug)) errs.slug = "Slug must be lowercase letters, numbers, and hyphens only";
        return errs;
    }

    async function handleSubmit(e) {
        e.preventDefault();
        const errs = validate();
        if (Object.keys(errs).length) { setErrors(errs); return; }

        setLoading(true);
        try {
            const payload = { ...form };   // tags is array of strings
            const savedPost = isEditing
                ? await postsApi.update(postId, payload)
                : await postsApi.create(payload);
            navigate(`/posts/${savedPost.id}`, { replace: true });
        } catch (err) {
            if (err.response?.status === 409) {
                setErrors({ slug: "This slug is already taken" });
            } else if (err.response?.status === 422) {
                const fieldErrors = {};
                err.response.data.detail.forEach((issue) => {
                    fieldErrors[issue.loc[issue.loc.length - 1]] = issue.msg;
                });
                setErrors(fieldErrors);
            } else {
                setErrors({ _general: err.response?.data?.detail ?? "Save failed" });
            }
        } finally {
            setLoading(false);
        }
    }

    if (isFetching) return <div className="text-center py-8">Loading...</div>;

    return (
        <div className="max-w-2xl mx-auto">
            <h1 className="text-2xl font-bold mb-6">{isEditing ? "Edit Post" : "New Post"}</h1>
            {errors._general && (
                <div className="bg-red-50 text-red-700 px-4 py-2 rounded mb-4">{errors._general}</div>
            )}
            <form onSubmit={handleSubmit} noValidate className="space-y-5">
                <div>
                    <label htmlFor="title" className="block text-sm font-medium mb-1">Title</label>
                    <input id="title" name="title" value={form.title}
                           onChange={handleTitleChange}
                           className={`w-full border rounded-lg px-3 py-2 ${errors.title ? "border-red-500" : ""}`} />
                    {errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>}
                </div>
                <div>
                    <label htmlFor="slug" className="block text-sm font-medium mb-1">Slug</label>
                    <input id="slug" name="slug" value={form.slug}
                           onChange={(e) => { setSlugEdited(true); handleChange(e); }}
                           className={`w-full border rounded-lg px-3 py-2 font-mono text-sm ${errors.slug ? "border-red-500" : ""}`} />
                    {errors.slug && <p className="text-red-500 text-sm mt-1">{errors.slug}</p>}
                </div>
                <div>
                    <label htmlFor="body" className="block text-sm font-medium mb-1">Body</label>
                    <textarea id="body" name="body" value={form.body}
                              onChange={handleChange} rows={12}
                              className={`w-full border rounded-lg px-3 py-2 font-mono ${errors.body ? "border-red-500" : ""}`} />
                    {errors.body && <p className="text-red-500 text-sm mt-1">{errors.body}</p>}
                </div>
                <div>
                    <label htmlFor="status" className="block text-sm font-medium mb-1">Status</label>
                    <select id="status" name="status" value={form.status}
                            onChange={handleChange}
                            className="border rounded-lg px-3 py-2">
                        <option value="draft">Draft</option>
                        <option value="published">Published</option>
                    </select>
                </div>
                <button type="submit" disabled={isLoading}
                        className="bg-blue-600 text-white px-6 py-2 rounded-lg disabled:opacity-50">
                    {isLoading ? "Saving..." : (isEditing ? "Save Changes" : "Publish Post")}
                </button>
            </form>
        </div>
    );
}
Note: The slugEdited flag tracks whether the user has manually changed the slug. When false, the slug auto-updates as the user types the title. Once the user edits the slug field directly (setSlugEdited(true)), the auto-generation stops — preserving the user’s intentional slug. This is the same pattern used by WordPress and Ghost blog editors.
Tip: For the tag input, a simple approach is a text input that adds a tag when the user presses Enter or comma, with a small ✕ button next to each existing tag. This is more usable than a plain comma-separated text field. The next lesson builds this as a reusable TagInput component that is used here.
Warning: When loading an existing post for editing (useEffect with postId dependency), always clear the form state at the start of the effect. If the user navigates from editing post 1 to editing post 2 without unmounting the component (same page, different URL params), the previous post’s data may flash briefly before the new data loads. Set setFetching(true) at the top of the effect to show a loading state while the new post fetches.

Common Mistakes

Mistake 1 — Not detecting the isEditing state

❌ Wrong — always calls POST even when editing:

await postsApi.create(form);   // creates a duplicate instead of updating!

✅ Correct — check postId from useParams to decide POST vs PATCH.

Mistake 2 — Auto-overwriting user’s manual slug during title changes

❌ Wrong — slugify runs on every title change regardless:

setForm({ ...form, title: value, slug: slugify(value) });   // overwrites slug on every keystroke

✅ Correct — use a slugEdited flag to stop auto-generation once the user has touched the slug field.

🧠 Test Yourself

A user navigates from /posts/new to /posts/42/edit. The PostEditorPage component stays mounted (same route element). What ensures the editor loads post 42’s data?