This lesson assembles all the concepts from the chapter — component composition, prop design, conditional rendering, and list rendering — into a complete, working component library for the blog application. Every component is wired to a realistic prop interface, handles its loading and empty states, and could be dropped into a real application with minimal changes. This is the foundation the React sections of the course will build on when we add state (Chapter 35), data fetching (Chapter 36), and routing (Chapter 37).
Skeleton Loading Component
// src/components/ui/Skeleton.jsx
// Animated placeholder shown while content loads
function SkeletonLine({ width = "w-full", height = "h-4" }) {
return (
<div className={`${width} ${height} bg-gray-200 rounded animate-pulse`} />
);
}
function PostCardSkeleton() {
return (
<article className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div className="flex justify-between mb-3">
<SkeletonLine width="w-20" height="h-5" />
<SkeletonLine width="w-16" height="h-5" />
</div>
<SkeletonLine width="w-3/4" height="h-6" />
<div className="mt-2 space-y-2">
<SkeletonLine />
<SkeletonLine width="w-5/6" />
<SkeletonLine width="w-4/6" />
</div>
<div className="flex gap-2 mt-3">
<SkeletonLine width="w-16" height="h-5" />
<SkeletonLine width="w-20" height="h-5" />
</div>
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-gray-50">
<div className="w-8 h-8 rounded-full bg-gray-200 animate-pulse" />
<SkeletonLine width="w-32" height="h-4" />
</div>
</article>
);
}
Note: Skeleton screens (animated placeholder shapes) provide a better loading experience than spinners for content-heavy lists because they set spatial expectations — the user can see approximately where the title, body, and metadata will appear before the data arrives. The
animate-pulse Tailwind class creates a subtle pulsing animation. Match the skeleton’s shape to the real content’s layout as closely as practical for the smoothest perceived transition.Tip: Export all your UI components from a single
src/components/ui/index.js barrel file: export { Button } from "./Button"; export { Badge } from "./Badge"; export { Avatar } from "./Avatar";. Then import as import { Button, Badge, Avatar } from "@/components/ui" — much cleaner than individual imports from each file. Update the barrel file whenever you add a new UI component.Warning: The
animate-pulse animation uses CSS opacity transitions. On low-power devices or when many skeletons are shown simultaneously, this animation can cause performance issues. If you show more than ~20 skeletons at once (an infinite scroll initial load), consider using a single shimmer animation across the whole container instead of individual pulsing elements. For most blog applications, 5–10 skeleton cards is the norm.Complete PostCard Component
// src/components/post/PostCard.jsx
import PropTypes from "prop-types";
import Avatar from "@/components/ui/Avatar";
import Badge from "@/components/ui/Badge";
function PostCard({ post, onLike, onTagClick, compact = false }) {
const date = post.created_at
? new Date(post.created_at).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
})
: null;
return (
<article className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 hover:shadow-md transition-shadow">
{/* Header: status badge + view count */}
<div className="flex items-center justify-between mb-2">
<Badge variant={post.status}>{post.status}</Badge>
{post.view_count > 0 && (
<span className="text-xs text-gray-400">{post.view_count} views</span>
)}
</div>
{/* Title */}
<h2 className="text-lg font-semibold text-gray-900 line-clamp-2 mb-1">
{post.title}
</h2>
{/* Body — hidden in compact mode */}
{!compact && post.body && (
<p className="text-gray-500 text-sm line-clamp-3 mb-3">{post.body}</p>
)}
{/* Tags */}
{post.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{post.tags.map((tag) => (
<button
key={tag.id}
onClick={() => onTagClick?.(tag.slug)}
className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full hover:bg-blue-100"
>
{tag.name}
</button>
))}
</div>
)}
{/* Footer: author + like button */}
<div className="flex items-center justify-between pt-3 border-t border-gray-50">
<div className="flex items-center gap-2">
<Avatar src={post.author?.avatar_url} name={post.author?.name ?? "?"} size={24} />
<span className="text-sm text-gray-500">
{post.author?.name}
{date && <span> · {date}</span>}
</span>
</div>
{onLike && (
<button
onClick={() => onLike(post.id)}
className="flex items-center gap-1 text-sm text-gray-400 hover:text-red-500 transition-colors"
>
♥ {post.like_count ?? 0}
</button>
)}
</div>
</article>
);
}
PostCard.propTypes = {
post: PropTypes.shape({
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
body: PropTypes.string,
status: PropTypes.string.isRequired,
view_count: PropTypes.number,
like_count: PropTypes.number,
created_at: PropTypes.string,
author: PropTypes.shape({
name: PropTypes.string,
avatar_url: PropTypes.string,
}),
tags: PropTypes.arrayOf(
PropTypes.shape({ id: PropTypes.number, name: PropTypes.string })
),
}).isRequired,
onLike: PropTypes.func,
onTagClick: PropTypes.func,
compact: PropTypes.bool,
};
export default PostCard;
PostList with All States
// src/components/post/PostList.jsx
import PostCard from "./PostCard";
import PostCardSkeleton from "@/components/ui/PostCardSkeleton";
function PostList({ posts = [], isLoading = false, error = null, onLike, onTagClick }) {
if (isLoading) {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => <PostCardSkeleton key={i} />)}
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-500 text-sm">{error.message ?? "Failed to load posts"}</p>
</div>
);
}
if (posts.length === 0) {
return (
<div className="text-center py-16 text-gray-400">
<p className="text-lg">No posts here yet.</p>
</div>
);
}
return (
<div className="grid gap-4">
{posts.map((post) => (
<PostCard key={post.id} post={post} onLike={onLike} onTagClick={onTagClick} />
))}
</div>
);
}
export default PostList;
Component Library Barrel Export
// src/components/ui/index.js
export { default as Avatar } from "./Avatar";
export { default as Badge } from "./Badge";
export { default as Button } from "./Button";
export { default as PostCardSkeleton} from "./PostCardSkeleton";
// src/components/post/index.js
export { default as PostCard } from "./PostCard";
export { default as PostList } from "./PostList";
// Usage anywhere in the app:
import { Button, Badge, Avatar } from "@/components/ui";
import { PostCard, PostList } from "@/components/post";
Quick Reference
| Component | Props | States Handled |
|---|---|---|
PostCard |
post, onLike, onTagClick, compact | missing author, no tags, no body |
PostList |
posts, isLoading, error, onLike, onTagClick | loading, error, empty, has data |
PostCardSkeleton |
none | always shows skeleton |
Avatar |
src, name, size | fallback to initials avatar API |
Badge |
children, variant | unknown variant falls back to default |