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;
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.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 |