Next js

Module 4: Building Components-Reusable UI Patterns with Client/Server Components, Props, Slots, and State Handling

Reusable UI Patterns with Client/Server Components, Props, Slots, and State Handling

A clean UI is made up of modular, reusable components — think buttons, cards, layouts, and forms. In this module, you’ll learn how to properly build these components using TypeScript, the App Router, and the Client vs Server Component model in Next.js 14.


⚙️ Client vs Server Components — The Core Idea

Next.js 14 separates components into two types:

Component Type Purpose Usage
Server Component Default (no "use client") Data fetching, backend logic
Client Component Marked with "use client" at top Hooks, interactivity, state

This allows your app to reduce JS on the client, improving performance. You only use client components when necessary.


🔁 Example: A Static Hero Server Component

// src/components/Hero.tsx (Server Component)
export default function Hero() {
  return (
    <section className="bg-blue-100 py-12 text-center">
      <h1 className="text-4xl font-bold">Welcome to Our App</h1>
      <p className="text-lg mt-2">Next.js 14 + TypeScript + Tailwind</p>
    </section>
  );
}

🧩 Creating Reusable Components

📦 Button.tsx (Client Component with Props)

// src/components/ui/Button.tsx
"use client";

type Props = {
  label: string;
  onClick?: () => void;
  variant?: "primary" | "secondary";
};

export default function Button({ label, onClick, variant = "primary" }: Props) {
  const base = "px-4 py-2 rounded font-semibold";
  const styles =
    variant === "primary"
      ? "bg-blue-600 text-white hover:bg-blue-700"
      : "bg-gray-200 text-black hover:bg-gray-300";

  return (
    <button onClick={onClick} className={`${base} ${styles}`}>
      {label}
    </button>
  );
}

🧪 Usage:

import Button from '@/components/ui/Button';

export default function ExamplePage() {
  return (
    <div className="p-8 space-y-4">
      <Button label="Click Me" onClick={() => alert("Hello!")} />
      <Button label="Secondary" variant="secondary" />
    </div>
  );
}

🃏 Card.tsx with children Slot

// src/components/ui/Card.tsx
type Props = {
  title: string;
  children: React.ReactNode;
};

export default function Card({ title, children }: Props) {
  return (
    <div className="p-4 rounded border shadow-sm bg-white dark:bg-gray-800">
      <h3 className="text-xl font-bold mb-2">{title}</h3>
      {children}
    </div>
  );
}

🧪 Usage:

import Card from "@/components/ui/Card";

<Card title="Card Title">
  <p>This is card content.</p>
</Card>

🧨 Handling Loading & Error States

Next.js encourages UI-based boundaries over try-catch.

loading.tsx (Auto-used while page loads)

// src/app/blog/loading.tsx
export default function Loading() {
  return <p className="text-center">⏳ Loading blog posts...</p>;
}

error.tsx with Error Boundary

// src/app/blog/error.tsx
'use client';

export default function Error({ error }: { error: Error }) {
  return <div className="text-red-500">Error loading blog: {error.message}</div>;
}

You can manually trigger this using throw new Error('Failed') inside a client component.


🔂 Dynamic Lists with Components

Here’s how you can dynamically render cards from an array of data:

🧪 BlogList.tsx

// src/components/BlogList.tsx
type Post = {
  id: number;
  title: string;
  content: string;
};

const posts: Post[] = [
  { id: 1, title: "First Post", content: "This is the first blog post." },
  { id: 2, title: "Second Post", content: "This is the second blog post." },
];

import Card from "./ui/Card";

export default function BlogList() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
      {posts.map((post) => (
        <Card key={post.id} title={post.title}>
          <p>{post.content}</p>
        </Card>
      ))}
    </div>
  );
}

Full Code Here:

/*
Project Structure:

src/
├── app/
│   ├── blog/
│   │   ├── loading.tsx
│   │   ├── error.tsx
│   │   └── page.tsx (optional demo page)
├── components/
│   ├── BlogList.tsx
│   ├── Hero.tsx
│   └── ui/
│       ├── Button.tsx
│       └── Card.tsx
*/

// src/components/Hero.tsx (Server Component)
export default function Hero() {
  return (
    <section className="bg-blue-100 py-12 text-center">
      <h1 className="text-4xl font-bold">Welcome to Our App</h1>
      <p className="text-lg mt-2">Next.js 14 + TypeScript + Tailwind</p>
    </section>
  );
}

// src/components/ui/Button.tsx (Client Component)
"use client";

type ButtonProps = {
  label: string;
  onClick?: () => void;
  variant?: "primary" | "secondary";
};

export default function Button({ label, onClick, variant = "primary" }: ButtonProps) {
  const base = "px-4 py-2 rounded font-semibold";
  const styles =
    variant === "primary"
      ? "bg-blue-600 text-white hover:bg-blue-700"
      : "bg-gray-200 text-black hover:bg-gray-300";

  return (
    <button onClick={onClick} className={`${base} ${styles}`}>
      {label}
    </button>
  );
}

// src/components/ui/Card.tsx
import React from "react";

type CardProps = {
  title: string;
  children: React.ReactNode;
};

export default function Card({ title, children }: CardProps) {
  return (
    <div className="p-4 rounded border shadow-sm bg-white dark:bg-gray-800">
      <h3 className="text-xl font-bold mb-2">{title}</h3>
      {children}
    </div>
  );
}

// src/components/BlogList.tsx
import Card from "./ui/Card";

type Post = {
  id: number;
  title: string;
  content: string;
};

const posts: Post[] = [
  { id: 1, title: "First Post", content: "This is the first blog post." },
  { id: 2, title: "Second Post", content: "This is the second blog post." },
];

export default function BlogList() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
      {posts.map((post) => (
        <Card key={post.id} title={post.title}>
          <p>{post.content}</p>
        </Card>
      ))}
    </div>
  );
}

// src/app/blog/loading.tsx
export default function Loading() {
  return <p className="text-center">⏳ Loading blog posts...</p>;
}

// src/app/blog/error.tsx
"use client";

export default function Error({ error }: { error: Error }) {
  return <div className="text-red-500">Error loading blog: {error.message}</div>;
}

// src/app/page.tsx (Example usage)
import Hero from "../components/Hero";
import BlogList from "../components/BlogList";
import Button from "../components/ui/Button";

export default function HomePage() {
  return (
    <main className="space-y-10 p-6">
      <Hero />
      <section className="text-center">
        <Button label="Click Me" onClick={() => alert("Hello!")} />
        <Button label="Secondary" variant="secondary" />
      </section>
      <BlogList />
    </main>
  );
}

 


🧠 Tips for Scalable Components

  • Use folders: components/ui/, components/shared/, etc.
  • Keep client components minimal — offload logic to server components if possible.
  • Use props, default values, and children for flexibility.
  • Create small, testable components.

✅ Summary

You now know how to:

  • Build Client and Server Components the right way
  • Use props, children, and component slots
  • Implement loading/error boundaries with App Router
  • Create responsive and reusable UI components using Tailwind CSS

🔜 Next Up:

In Module 5, we’ll dive into State Management — from useState to useContext, and advanced setups using Zustand and Redux Toolkit.

Would you like this module as Markdown, HTML, or embedded in your blog structure?

Leave a Reply

Your email address will not be published. Required fields are marked *