Putting It Together — Blog UI Component Library

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

🧠 Test Yourself

The PostList component receives posts=[], isLoading=false, and error=null. In what order do the three conditional checks run, and what renders?