Building the Post Service — CRUD Operations from React

The post service is the most-used module in the MERN Blog client — it connects every post-related component to the Express API. Building it well means centralising all post API logic in one place, making components lean, and ensuring consistent error handling across the entire application. In this lesson you will build the complete postService, integrate it with the PostListPage, PostDetailPage, and CreatePostPage components, and wire up delete and update operations to produce a fully functional, connected MERN Blog frontend.

The Complete Post Service

// src/services/postService.js
import api from './api';

const postService = {
  // GET /api/posts?page=1&limit=10&published=true&tag=mern
  getAll: (params = {}) =>
    api.get('/posts', { params }),

  // GET /api/posts/featured
  getFeatured: () =>
    api.get('/posts/featured'),

  // GET /api/posts/:id
  getById: (id, signal) =>
    api.get(`/posts/${id}`, { signal }),

  // GET /api/posts/slug/:slug
  getBySlug: (slug, signal) =>
    api.get(`/posts/slug/${slug}`, { signal }),

  // POST /api/posts  (protected)
  create: (data) =>
    api.post('/posts', data),

  // PATCH /api/posts/:id  (protected — owner/admin)
  update: (id, data) =>
    api.patch(`/posts/${id}`, data),

  // DELETE /api/posts/:id  (protected — owner/admin)
  remove: (id) =>
    api.delete(`/posts/${id}`),

  // PATCH /api/posts/:id/publish  (protected — owner)
  publish: (id) =>
    api.patch(`/posts/${id}/publish`),

  // GET /api/users/:userId/posts
  getByUser: (userId, params = {}) =>
    api.get(`/users/${userId}/posts`, { params }),
};

export default postService;
Note: Service methods return the raw Axios Promise — they do not await or catch inside the service. The component or hook that calls the service is responsible for handling loading, error, and data state. This keeps the service pure and reusable: one service method can be called from a page component, a custom hook, or a context provider — each with its own state management strategy.
Tip: Pass the AbortController signal as a parameter to service methods that need cancellation support (individual resource fetches). For list fetches that are cancelled via cleanup in useEffect, pass the signal from inside the effect: const controller = new AbortController(); postService.getById(id, controller.signal). This makes cancellation opt-in without forcing it on every method.
Warning: The remove (delete) operation is irreversible on the backend. Before calling postService.remove(id), show a confirmation dialog. Accidental clicks on a delete button should never immediately destroy data. Use a simple window.confirm() during development or build a proper ConfirmModal component for production.

Integrating with PostListPage

// src/pages/HomePage.jsx
import { useState, useEffect }      from 'react';
import postService                   from '@/services/postService';
import PostList                      from '@/components/posts/PostList';

function HomePage() {
  const [posts,   setPosts]   = useState([]);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);
  const [total,   setTotal]   = useState(0);
  const [page,    setPage]    = useState(1);
  const LIMIT = 10;

  useEffect(() => {
    const controller = new AbortController();

    const fetchPosts = async () => {
      try {
        setLoading(true);
        setError(null);
        const res = await postService.getAll({
          page,
          limit:     LIMIT,
          published: true,
        });
        setPosts(res.data.data);
        setTotal(res.data.total);
      } catch (err) {
        if (!err.name?.includes('Cancel')) {
          setError(err.response?.data?.message || 'Could not load posts');
        }
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();
    return () => controller.abort();
  }, [page]);

  return (
    <div className="home-page">
      <h1>Latest Posts</h1>
      <PostList posts={posts} loading={loading} error={error} />
      <Pagination
        page={page}
        total={total}
        limit={LIMIT}
        onPageChange={setPage}
      />
    </div>
  );
}

Integrating with PostDetailPage

// src/pages/PostDetailPage.jsx
import { useState, useEffect }              from 'react';
import { useParams, useNavigate, Link }     from 'react-router-dom';
import postService                          from '@/services/postService';
import { useAuth }                          from '@/context/AuthContext';
import Spinner                              from '@/components/ui/Spinner';
import ErrorMessage                         from '@/components/ui/ErrorMessage';

function PostDetailPage() {
  const { id }     = useParams();
  const navigate   = useNavigate();
  const { user }   = useAuth();
  const [post,    setPost]    = useState(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);
  const [deleting, setDeleting] = useState(false);

  useEffect(() => {
    const controller = new AbortController();
    const fetchPost  = async () => {
      try {
        setPost(null);
        setLoading(true);
        setError(null);
        const res = await postService.getById(id, controller.signal);
        setPost(res.data.data);
      } catch (err) {
        if (!err.name?.includes('Cancel')) {
          setError(err.response?.status === 404 ? 'Post not found' : 'Failed to load post');
        }
      } finally {
        setLoading(false);
      }
    };
    fetchPost();
    return () => controller.abort();
  }, [id]);

  const handleDelete = async () => {
    if (!window.confirm('Delete this post? This cannot be undone.')) return;
    setDeleting(true);
    try {
      await postService.remove(id);
      navigate('/dashboard'); // redirect after delete
    } catch (err) {
      alert(err.response?.data?.message || 'Failed to delete post');
      setDeleting(false);
    }
  };

  if (loading) return <Spinner message="Loading post..." />;
  if (error)   return <ErrorMessage message={error} />;
  if (!post)   return null;

  const isOwner = user?._id === post.author?._id || user?.role === 'admin';

  return (
    <article className="post-detail">
      <h1>{post.title}</h1>
      <div className="post-detail__body"
           dangerouslySetInnerHTML={{ __html: post.body }} />

      {isOwner && (
        <div className="post-detail__actions">
          <Link to={`/posts/${id}/edit`} className="btn btn--secondary">Edit</Link>
          <button
            onClick={handleDelete}
            disabled={deleting}
            className="btn btn--danger"
          >
            {deleting ? 'Deleting...' : 'Delete Post'}
          </button>
        </div>
      )}
    </article>
  );
}

The Edit Post Page — Pre-Populated Form

// src/pages/EditPostPage.jsx — load existing post then allow editing
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import postService from '@/services/postService';
import FormField   from '@/components/ui/FormField';
import TagInput    from '@/components/ui/TagInput';

function EditPostPage() {
  const { id }   = useParams();
  const navigate = useNavigate();
  const [formData, setFormData] = useState({
    title: '', body: '', excerpt: '', coverImage: '', tags: [], published: false,
  });
  const [loading,    setLoading]    = useState(true);
  const [submitting, setSubmitting] = useState(false);
  const [errors,     setErrors]     = useState({});

  // Load existing post data into the form
  useEffect(() => {
    const fetchPost = async () => {
      try {
        const res = await postService.getById(id);
        const { title, body, excerpt, coverImage, tags, published } = res.data.data;
        setFormData({ title, body, excerpt: excerpt || '', coverImage: coverImage || '', tags, published });
      } catch (err) {
        alert('Could not load post for editing');
        navigate('/dashboard');
      } finally {
        setLoading(false);
      }
    };
    fetchPost();
  }, [id, navigate]);

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setFormData(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value }));
    if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }));
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setSubmitting(true);
    try {
      await postService.update(id, formData);
      navigate(`/posts/${id}`);
    } catch (err) {
      const resData = err.response?.data;
      if (resData?.errors) {
        setErrors(Object.fromEntries(resData.errors.map(e => [e.field, e.message])));
      } else {
        alert(resData?.message || 'Update failed');
      }
    } finally {
      setSubmitting(false);
    }
  };

  if (loading) return <Spinner message="Loading post..." />;

  return (
    <div className="edit-post-page">
      <h1>Edit Post</h1>
      <form onSubmit={handleSubmit} noValidate>
        <FormField label="Title" name="title" value={formData.title}
          onChange={handleChange} error={errors.title} required disabled={submitting} />
        {/* ... other fields ... */}
        <button type="submit" disabled={submitting}>
          {submitting ? 'Saving...' : 'Save Changes'}
        </button>
      </form>
    </div>
  );
}

Common Mistakes

Mistake 1 — Not confirming before delete

❌ Wrong — delete fires immediately on single click:

<button onClick={() => postService.remove(id)}>Delete</button>
// Accidental click permanently deletes the post

✅ Correct — confirm before destructive operations:

const handleDelete = () => {
  if (!window.confirm('Delete this post?')) return;
  postService.remove(id).then(() => navigate('/dashboard'));
};

Mistake 2 — Not pre-populating the edit form

❌ Wrong — edit form starts with empty fields regardless of existing post data:

const [formData, setFormData] = useState({ title: '', body: '' });
// Never loads existing post data — user must re-type everything

✅ Correct — fetch the post and set formData from the response in useEffect.

Mistake 3 — Updating UI state before server confirms delete

❌ Wrong — removing the post from local state before the API responds:

setPosts(prev => prev.filter(p => p._id !== id)); // optimistic
await postService.remove(id); // if this fails, post is gone from UI but still exists!

✅ Safer — update UI only after server confirms:

await postService.remove(id);                         // wait for server
setPosts(prev => prev.filter(p => p._id !== id)); // then update UI ✓

Quick Reference

Operation Service Call
List posts postService.getAll({ page, limit, tag })
Single post postService.getById(id, signal)
Create post postService.create(formData)
Update post postService.update(id, formData)
Delete post postService.remove(id)
Access posts array res.data.data
Access total count res.data.total

🧠 Test Yourself

In the EditPostPage, why do you load the existing post data in useEffect and call setFormData instead of just initialising useState directly with the post data?