Theory becomes skill through building. In this lesson you will put together everything from this chapter — function components, props, composition, PropTypes, and default values — by building the complete reusable component library for the MERN Blog client. These are the components you will use in every remaining chapter: PostCard, PostList, Spinner, ErrorMessage, Badge, Avatar, and the PageLayout shell. Build them now and they will work immediately when you connect them to real data from the Express API in the chapters ahead.
Component Inventory
| Component | File | Purpose |
|---|---|---|
| PageLayout | components/layout/PageLayout.jsx | Header + main + footer shell for every page |
| Spinner | components/ui/Spinner.jsx | Loading indicator |
| ErrorMessage | components/ui/ErrorMessage.jsx | Styled error box with optional retry |
| Badge | components/ui/Badge.jsx | Small pill label for tags, status, roles |
| Avatar | components/ui/Avatar.jsx | Round author image with fallback |
| PostCard | components/posts/PostCard.jsx | Blog post summary card for list view |
| PostList | components/posts/PostList.jsx | Grid of PostCards with empty and loading states |
Note: The components built in this lesson use only static/prop-based data — no API calls, no state, no effects. They are pure presentational components: given the right props, they render the right UI. You will add live data in Chapters 20+ when you connect React to your Express API.
Tip: Build and verify each component in isolation by creating a
src/pages/ComponentDemo.jsx that imports all your components and renders them with hardcoded sample data. This visual playground lets you confirm each component looks right before integrating it into the real application flow.Warning: Keep all visual styles in CSS files, not in component logic. A component’s JSX should set class names based on props but the actual styling belongs in CSS. Mixing layout logic with CSS calculations in JavaScript makes components harder to maintain and override.
Spinner and ErrorMessage
// src/components/ui/Spinner.jsx
function Spinner({ size = 'medium', message = 'Loading...' }) {
return (
<div className={`spinner spinner--${size}`} role="status">
<div className="spinner__circle"></div>
{message && <p className="spinner__message">{message}</p>}
</div>
);
}
export default Spinner;
// src/components/ui/ErrorMessage.jsx
function ErrorMessage({ message, onRetry }) {
return (
<div className="error-message" role="alert">
<span>⚠️</span>
<p>{message}</p>
{onRetry && (
<button className="btn btn--secondary" onClick={onRetry}>Try Again</button>
)}
</div>
);
}
export default ErrorMessage;
Badge and Avatar
// src/components/ui/Badge.jsx
function Badge({ label, variant = 'default' }) {
return <span className={`badge badge--${variant}`}>{label}</span>;
}
export default Badge;
// src/components/ui/Avatar.jsx
function Avatar({ src, name, size = 40 }) {
const fallback = `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&size=${size}`;
return (
<img
className="avatar"
src={src || fallback}
alt={`${name}'s avatar`}
width={size}
height={size}
style={{ borderRadius: '50%', objectFit: 'cover' }}
onError={e => { e.target.src = fallback; }}
/>
);
}
export default Avatar;
PostCard Component
// src/components/posts/PostCard.jsx
import Avatar from '@/components/ui/Avatar';
import Badge from '@/components/ui/Badge';
import PropTypes from 'prop-types';
function PostCard({ post, onDelete, isOwner = false }) {
const { _id, title, excerpt, author, tags = [], viewCount = 0, createdAt, featured } = post;
const formattedDate = new Date(createdAt).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
});
return (
<article className={`post-card${featured ? ' post-card--featured' : ''}`}>
{featured && <Badge label="Featured" variant="info" />}
<h2 className="post-card__title">{title}</h2>
{excerpt && <p className="post-card__excerpt">{excerpt}</p>}
<div className="post-card__meta">
<Avatar src={author?.avatar} name={author?.name || 'Unknown'} size={32} />
<span>{author?.name || 'Unknown'}</span>
<time dateTime={createdAt}>{formattedDate}</time>
<span>👁 {viewCount}</span>
</div>
{tags.length > 0 && (
<div className="post-card__tags">
{tags.map(tag => <Badge key={tag} label={`#${tag}`} variant="tag" />)}
</div>
)}
{isOwner && (
<div className="post-card__actions">
<button className="btn btn--sm btn--danger" onClick={() => onDelete?.(_id)}>
Delete
</button>
</div>
)}
</article>
);
}
PostCard.propTypes = {
post: PropTypes.shape({
_id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
excerpt: PropTypes.string,
viewCount: PropTypes.number,
featured: PropTypes.bool,
createdAt: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
author: PropTypes.shape({ name: PropTypes.string, avatar: PropTypes.string }),
}).isRequired,
isOwner: PropTypes.bool,
onDelete: PropTypes.func,
};
export default PostCard;
PostList Component
// src/components/posts/PostList.jsx
import PostCard from './PostCard';
import Spinner from '@/components/ui/Spinner';
import ErrorMessage from '@/components/ui/ErrorMessage';
import PropTypes from 'prop-types';
function PostList({ posts = [], loading = false, error = null, onRetry, currentUserId }) {
if (loading) return <Spinner message="Loading posts..." />;
if (error) return <ErrorMessage message={error} onRetry={onRetry} />;
if (posts.length === 0) {
return <p className="post-list__empty">No posts found. Be the first to write one!</p>;
}
return (
<div className="post-list">
{posts.map(post => (
<PostCard
key={post._id}
post={post}
isOwner={currentUserId === post.author?._id}
/>
))}
</div>
);
}
PostList.propTypes = {
posts: PropTypes.arrayOf(PropTypes.shape({ _id: PropTypes.string.isRequired })),
loading: PropTypes.bool,
error: PropTypes.string,
onRetry: PropTypes.func,
currentUserId: PropTypes.string,
};
export default PostList;
PageLayout Shell
// src/components/layout/PageLayout.jsx
import PropTypes from 'prop-types';
function PageLayout({ children }) {
return (
<div className="page-layout">
<header className="site-header">
<a href="/" className="site-header__logo">MERN Blog</a>
<nav>
<a href="/">Home</a>
<a href="/login">Login</a>
<a href="/register">Register</a>
</nav>
</header>
<main className="page-layout__content">{children}</main>
<footer className="site-footer">
<p>© {new Date().getFullYear()} MERN Blog</p>
</footer>
</div>
);
}
PageLayout.propTypes = { children: PropTypes.node.isRequired };
export default PageLayout;
Component Checklist
| Check before considering a component done | |
|---|---|
| Capitalised function name, matching file name | ✓ |
| Props destructured with sensible defaults | ✓ |
| PropTypes defined for all props | ✓ |
| Children rendered if the component is a wrapper | ✓ |
| Loading, error, and empty states handled | ✓ |
| Callback prop defaults prevent “not a function” crashes | ✓ |
| Keys on all mapped elements use stable IDs | ✓ |