Back to blog

Phase 2: Frontend Development with TypeScript

typescriptreactnextjsfrontendweb-development
Phase 2: Frontend Development with TypeScript

Welcome to Phase 2

Now that you've mastered TypeScript fundamentals in Phase 1, it's time to put those skills to work building modern frontend applications. In this phase, you'll learn how to combine TypeScript with React 19 and Next.js 15 to create fast, type-safe user interfaces.

Frontend development with TypeScript is where the type system truly shines. You'll catch prop errors at compile time, get autocompletion for component APIs, and build UIs that are easier to refactor and maintain.

Time commitment: 3 weeks, 1-2 hours daily Prerequisites: Phase 1: TypeScript Fundamentals

What You'll Learn

By the end of Phase 2, you'll be able to:

✅ Build type-safe React components with proper prop typing
✅ Use React hooks with full TypeScript support
✅ Create custom hooks with generic types
✅ Work with Next.js 15 App Router and Server Components
✅ Implement Server Actions with type safety
✅ Manage state with Zustand and TanStack Query
✅ Handle forms with React Hook Form + Zod validation
✅ Style applications with Tailwind CSS and Shadcn/ui
✅ Configure build tools (Vite, Turbopack)

Setting Up a React + TypeScript Project

npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir
cd my-app
npm run dev

This gives you:

  • TypeScript configured out of the box
  • App Router with Server Components
  • Tailwind CSS ready
  • ESLint with Next.js rules
  • Turbopack for fast development builds

Option 2: Vite + React

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

Vite provides a fast, lightweight setup for client-side React apps.

Project Structure (Next.js 15)

src/
├── app/                    # App Router pages
│   ├── layout.tsx          # Root layout
│   ├── page.tsx            # Home page
│   └── blog/
│       ├── page.tsx        # Blog listing
│       └── [slug]/
│           └── page.tsx    # Dynamic blog post
├── components/             # Reusable components
│   ├── ui/                 # Base UI components
│   ├── Header.tsx
│   └── Footer.tsx
├── lib/                    # Utility functions
│   ├── utils.ts
│   └── api.ts
├── hooks/                  # Custom React hooks
│   └── useDebounce.ts
└── types/                  # Shared type definitions
    └── index.ts

React Components with TypeScript

Function Components and Props

// Define props with an interface
interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: "primary" | "secondary" | "danger";
  disabled?: boolean;
  size?: "sm" | "md" | "lg";
}
 
function Button({ label, onClick, variant = "primary", disabled = false, size = "md" }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant} btn-${size}`}
    >
      {label}
    </button>
  );
}
 
// Usage - TypeScript checks all props
<Button label="Submit" onClick={() => console.log("clicked")} />
<Button label="Delete" onClick={handleDelete} variant="danger" />
// <Button label="Test" /> // Error: onClick is required

Children Props

// Using React.ReactNode for flexible children
interface CardProps {
  title: string;
  children: React.ReactNode;
  className?: string;
}
 
function Card({ title, children, className }: CardProps) {
  return (
    <div className={`card ${className ?? ""}`}>
      <h2>{title}</h2>
      <div>{children}</div>
    </div>
  );
}
 
// Using React.PropsWithChildren as a shortcut
type PanelProps = React.PropsWithChildren<{
  title: string;
}>;
 
function Panel({ title, children }: PanelProps) {
  return (
    <section>
      <h3>{title}</h3>
      {children}
    </section>
  );
}

Generic Components

// A generic list component
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
}
 
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}
 
// Usage - TypeScript infers T from items
interface User {
  id: string;
  name: string;
  email: string;
}
 
const users: User[] = [
  { id: "1", name: "Alice", email: "alice@example.com" },
  { id: "2", name: "Bob", email: "bob@example.com" },
];
 
<List
  items={users}
  keyExtractor={(user) => user.id}
  renderItem={(user) => <span>{user.name} ({user.email})</span>}
/>

Event Handlers

// Common event handler types
function Form() {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };
 
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // Handle form submission
  };
 
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    console.log("Button clicked at:", e.clientX, e.clientY);
  };
 
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") {
      // Handle enter key
    }
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} onKeyDown={handleKeyDown} />
      <button onClick={handleClick}>Submit</button>
    </form>
  );
}

React Hooks with TypeScript

useState

// TypeScript infers the type from initial value
const [count, setCount] = useState(0);          // number
const [name, setName] = useState("Alice");      // string
const [isOpen, setIsOpen] = useState(false);    // boolean
 
// Explicit type for complex state
interface User {
  id: string;
  name: string;
  email: string;
}
 
const [user, setUser] = useState<User | null>(null);
 
// Update with type safety
setUser({ id: "1", name: "Alice", email: "alice@example.com" });
// setUser({ id: "1", name: "Alice" }); // Error: missing email

useRef

// DOM element ref
const inputRef = useRef<HTMLInputElement>(null);
 
function focusInput() {
  inputRef.current?.focus(); // Safe access with optional chaining
}
 
return <input ref={inputRef} />;
 
// Mutable ref (stores a value, not a DOM element)
const renderCount = useRef<number>(0);
renderCount.current += 1; // No need for optional chaining

useReducer

// Define state and action types
interface TodoState {
  todos: { id: string; text: string; done: boolean }[];
  filter: "all" | "active" | "completed";
}
 
type TodoAction =
  | { type: "ADD_TODO"; text: string }
  | { type: "TOGGLE_TODO"; id: string }
  | { type: "DELETE_TODO"; id: string }
  | { type: "SET_FILTER"; filter: TodoState["filter"] };
 
function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case "ADD_TODO":
      return {
        ...state,
        todos: [...state.todos, { id: crypto.randomUUID(), text: action.text, done: false }],
      };
    case "TOGGLE_TODO":
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, done: !todo.done } : todo
        ),
      };
    case "DELETE_TODO":
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    case "SET_FILTER":
      return { ...state, filter: action.filter };
    default:
      return state;
  }
}
 
// Usage in component
const [state, dispatch] = useReducer(todoReducer, {
  todos: [],
  filter: "all",
});
 
dispatch({ type: "ADD_TODO", text: "Learn TypeScript" });
dispatch({ type: "TOGGLE_TODO", id: "abc-123" });
// dispatch({ type: "ADD_TODO" }); // Error: missing text property

useContext with TypeScript

// Define context type
interface ThemeContextType {
  theme: "light" | "dark";
  toggleTheme: () => void;
}
 
// Create context with default value
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
 
// Custom hook for safe context access
function useTheme(): ThemeContextType {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}
 
// Provider component
function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<"light" | "dark">("light");
 
  const toggleTheme = () => {
    setTheme((prev) => (prev === "light" ? "dark" : "light"));
  };
 
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
 
// Usage
function Header() {
  const { theme, toggleTheme } = useTheme(); // Fully typed
  return <button onClick={toggleTheme}>Current: {theme}</button>;
}

Custom Hooks

Generic Data Fetching Hook

interface UseFetchResult<T> {
  data: T | null;
  error: string | null;
  isLoading: boolean;
}
 
function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);
 
  useEffect(() => {
    const controller = new AbortController();
 
    async function fetchData() {
      try {
        setIsLoading(true);
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const json = await response.json();
        setData(json as T);
      } catch (err) {
        if (err instanceof Error && err.name !== "AbortError") {
          setError(err.message);
        }
      } finally {
        setIsLoading(false);
      }
    }
 
    fetchData();
    return () => controller.abort();
  }, [url]);
 
  return { data, error, isLoading };
}
 
// Usage - T is inferred or specified
interface Post {
  id: number;
  title: string;
  body: string;
}
 
function BlogPosts() {
  const { data: posts, error, isLoading } = useFetch<Post[]>("/api/posts");
 
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
 
  return (
    <ul>
      {posts?.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Debounce Hook

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
 
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
 
  return debouncedValue;
}
 
// Usage
function SearchBar() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);
 
  useEffect(() => {
    if (debouncedQuery) {
      // Fetch search results
      console.log("Searching for:", debouncedQuery);
    }
  }, [debouncedQuery]);
 
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

Next.js 15 with TypeScript

Server Components (Default)

In Next.js 15, all components in the app/ directory are Server Components by default. They run on the server and can directly access databases, files, and other server-side resources.

// app/blog/page.tsx - Server Component (no "use client" needed)
import { getAllPosts } from "@/lib/posts";
 
interface Post {
  slug: string;
  title: string;
  description: string;
  date: string;
}
 
export default async function BlogPage() {
  const posts: Post[] = await getAllPosts();
 
  return (
    <main>
      <h1>Blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>
            <a href={`/blog/${post.slug}`}>
              <h2>{post.title}</h2>
              <p>{post.description}</p>
              <time>{post.date}</time>
            </a>
          </li>
        ))}
      </ul>
    </main>
  );
}

Client Components

Add "use client" at the top of files that need browser APIs, event handlers, or React state.

"use client";
 
import { useState } from "react";
 
interface CounterProps {
  initialCount?: number;
  step?: number;
}
 
export default function Counter({ initialCount = 0, step = 1 }: CounterProps) {
  const [count, setCount] = useState(initialCount);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + step)}>+{step}</button>
      <button onClick={() => setCount((c) => c - step)}>-{step}</button>
    </div>
  );
}

Dynamic Routes with TypeScript

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPostBySlug } from "@/lib/posts";
 
interface PageProps {
  params: Promise<{ slug: string }>;
}
 
export default async function BlogPostPage({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
 
  if (!post) {
    notFound();
  }
 
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.date}</time>
      <div>{post.content}</div>
    </article>
  );
}
 
// Generate metadata for SEO
export async function generateMetadata({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
 
  if (!post) return { title: "Not Found" };
 
  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      images: [post.image],
    },
  };
}

Server Actions

Server Actions let you run server-side code from client components with full type safety.

// app/actions.ts
"use server";
 
import { z } from "zod";
 
const ContactSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  message: z.string().min(10, "Message must be at least 10 characters"),
});
 
type ContactFormState = {
  success: boolean;
  errors?: Record<string, string[]>;
  message?: string;
};
 
export async function submitContact(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  const raw = {
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  };
 
  const result = ContactSchema.safeParse(raw);
 
  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }
 
  // Save to database, send email, etc.
  console.log("Contact submitted:", result.data);
 
  return { success: true, message: "Message sent successfully!" };
}
// app/contact/page.tsx
"use client";
 
import { useActionState } from "react";
import { submitContact } from "@/app/actions";
 
export default function ContactPage() {
  const [state, formAction, isPending] = useActionState(submitContact, {
    success: false,
  });
 
  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" required />
        {state.errors?.name && <p className="text-red-500">{state.errors.name[0]}</p>}
      </div>
 
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
        {state.errors?.email && <p className="text-red-500">{state.errors.email[0]}</p>}
      </div>
 
      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" name="message" required />
        {state.errors?.message && <p className="text-red-500">{state.errors.message[0]}</p>}
      </div>
 
      <button type="submit" disabled={isPending}>
        {isPending ? "Sending..." : "Send Message"}
      </button>
 
      {state.success && <p className="text-green-500">{state.message}</p>}
    </form>
  );
}

State Management

Zustand (Lightweight Store)

Zustand provides a simple, type-safe store without boilerplate.

import { create } from "zustand";
 
// Define store types
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}
 
interface CartStore {
  items: CartItem[];
  addItem: (item: Omit<CartItem, "quantity">) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  total: () => number;
}
 
const useCartStore = create<CartStore>((set, get) => ({
  items: [],
 
  addItem: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id);
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        };
      }
      return { items: [...state.items, { ...item, quantity: 1 }] };
    }),
 
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((i) => i.id !== id),
    })),
 
  updateQuantity: (id, quantity) =>
    set((state) => ({
      items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
    })),
 
  clearCart: () => set({ items: [] }),
 
  total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));
 
// Usage in component
function CartSummary() {
  const items = useCartStore((state) => state.items);
  const total = useCartStore((state) => state.total());
  const clearCart = useCartStore((state) => state.clearCart);
 
  return (
    <div>
      <p>{items.length} items in cart</p>
      <p>Total: ${total.toFixed(2)}</p>
      <button onClick={clearCart}>Clear Cart</button>
    </div>
  );
}

TanStack Query (Data Fetching)

TanStack Query (React Query) manages server state with caching, refetching, and type safety.

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 
interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}
 
// Typed query function
async function fetchPosts(): Promise<Post[]> {
  const response = await fetch("/api/posts");
  if (!response.ok) throw new Error("Failed to fetch posts");
  return response.json();
}
 
async function createPost(data: Omit<Post, "id">): Promise<Post> {
  const response = await fetch("/api/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
  if (!response.ok) throw new Error("Failed to create post");
  return response.json();
}
 
// Query hook - data is typed as Post[] | undefined
function PostList() {
  const { data: posts, isLoading, error } = useQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  });
 
  const queryClient = useQueryClient();
 
  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });
 
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return (
    <div>
      <button
        onClick={() =>
          mutation.mutate({ title: "New Post", body: "Content", userId: 1 })
        }
      >
        Add Post
      </button>
      <ul>
        {posts?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Form Handling

React Hook Form + Zod

The combination of React Hook Form and Zod provides excellent type safety for forms.

"use client";
 
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
 
// Define schema with Zod
const signUpSchema = z
  .object({
    username: z
      .string()
      .min(3, "Username must be at least 3 characters")
      .max(20, "Username must be at most 20 characters"),
    email: z.string().email("Invalid email address"),
    password: z
      .string()
      .min(8, "Password must be at least 8 characters")
      .regex(/[A-Z]/, "Must contain an uppercase letter")
      .regex(/[0-9]/, "Must contain a number"),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords don't match",
    path: ["confirmPassword"],
  });
 
// Infer TypeScript type from Zod schema
type SignUpFormData = z.infer<typeof signUpSchema>;
 
export default function SignUpForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignUpFormData>({
    resolver: zodResolver(signUpSchema),
  });
 
  const onSubmit = async (data: SignUpFormData) => {
    // data is fully typed: { username, email, password, confirmPassword }
    console.log("Form submitted:", data);
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register("username")} placeholder="Username" />
        {errors.username && <p>{errors.username.message}</p>}
      </div>
 
      <div>
        <input {...register("email")} type="email" placeholder="Email" />
        {errors.email && <p>{errors.email.message}</p>}
      </div>
 
      <div>
        <input {...register("password")} type="password" placeholder="Password" />
        {errors.password && <p>{errors.password.message}</p>}
      </div>
 
      <div>
        <input {...register("confirmPassword")} type="password" placeholder="Confirm Password" />
        {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
      </div>
 
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Signing up..." : "Sign Up"}
      </button>
    </form>
  );
}

Styling with TypeScript

Tailwind CSS

Tailwind CSS works seamlessly with TypeScript. Use the cn() utility for conditional classes:

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
 
// Utility function for merging Tailwind classes
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}
 
// Usage in components
interface BadgeProps {
  label: string;
  variant: "success" | "warning" | "error" | "info";
}
 
function Badge({ label, variant }: BadgeProps) {
  return (
    <span
      className={cn(
        "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
        {
          "bg-green-100 text-green-800": variant === "success",
          "bg-yellow-100 text-yellow-800": variant === "warning",
          "bg-red-100 text-red-800": variant === "error",
          "bg-blue-100 text-blue-800": variant === "info",
        }
      )}
    >
      {label}
    </span>
  );
}

Shadcn/ui Components

Shadcn/ui provides copy-paste components with full TypeScript support:

npx shadcn@latest init
npx shadcn@latest add button card input
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
 
// All components are fully typed
function Dashboard() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Dashboard</CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        <Input placeholder="Search..." type="search" />
        <div className="flex gap-2">
          <Button variant="default">Save</Button>
          <Button variant="outline">Cancel</Button>
          <Button variant="destructive">Delete</Button>
        </div>
      </CardContent>
    </Card>
  );
}

Build Tools Configuration

Modern frontend development relies on build tools like Vite and webpack to bundle, optimize, and serve your code. For a deep dive into how these tools work under the hood, see JavaScript Build Tools & Bundlers Explained.

Vite with TypeScript

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
 
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
  server: {
    port: 3000,
    open: true,
  },
  build: {
    outDir: "dist",
    sourcemap: true,
  },
});

Next.js Configuration

// next.config.ts (Next.js 15 supports .ts config)
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  reactStrictMode: true,
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "images.unsplash.com",
      },
    ],
  },
  experimental: {
    typedRoutes: true, // Type-safe routing
  },
};
 
export default nextConfig;

TypeScript Configuration for React

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "incremental": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src", "next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

Common Patterns and Best Practices

Discriminated Union for UI States

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };
 
function UserProfile() {
  const [state, setState] = useState<AsyncState<User>>({ status: "idle" });
 
  switch (state.status) {
    case "idle":
      return <p>Click to load profile</p>;
    case "loading":
      return <p>Loading...</p>;
    case "success":
      return <p>Welcome, {state.data.name}!</p>; // data is typed
    case "error":
      return <p>Error: {state.error}</p>; // error is typed
  }
}

Forwarding Refs

import { forwardRef } from "react";
 
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}
 
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, className, ...props }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} className={className} {...props} />
        {error && <p className="text-red-500 text-sm">{error}</p>}
      </div>
    );
  }
);
 
Input.displayName = "Input";

Polymorphic Components

// Component that renders as different HTML elements
type AsProp<C extends React.ElementType> = {
  as?: C;
};
 
type PolymorphicProps<C extends React.ElementType, Props = object> = AsProp<C> &
  Props &
  Omit<React.ComponentPropsWithoutRef<C>, keyof AsProp<C> | keyof Props>;
 
interface TextOwnProps {
  size?: "sm" | "md" | "lg";
  weight?: "normal" | "bold";
}
 
function Text<C extends React.ElementType = "p">({
  as,
  size = "md",
  weight = "normal",
  children,
  ...props
}: PolymorphicProps<C, TextOwnProps>) {
  const Component = as || "p";
  return (
    <Component className={`text-${size} font-${weight}`} {...props}>
      {children}
    </Component>
  );
}
 
// Usage
<Text>Default paragraph</Text>
<Text as="h1" size="lg" weight="bold">Heading</Text>
<Text as="span" size="sm">Inline text</Text>

Summary and Key Takeaways

You've completed Phase 2 of the TypeScript roadmap. Here's what you've learned:

React Components: Props typing, children, generic components, event handlers
Hooks: useState, useRef, useReducer, useContext with full type safety
Custom Hooks: Generic hooks, data fetching, debounce patterns
Next.js 15: Server Components, Client Components, dynamic routes, Server Actions
State Management: Zustand stores, TanStack Query for server state
Forms: React Hook Form + Zod for validated, type-safe forms
Styling: Tailwind CSS utilities, Shadcn/ui components
Build Tools: Vite and Next.js configuration
Patterns: Discriminated unions, forwarding refs, polymorphic components

What's Next?

With frontend skills in place, Phase 3 will take you to the backend where you'll build type-safe APIs with Node.js, Express, and Prisma.

🎯 Continue to Phase 3: Backend Development →

Additional Resources

For a deeper dive into React + TypeScript patterns:

📘 Deep Dive: React with TypeScript Best Practices →


Previous: Phase 1: TypeScript Fundamentals → Next: Phase 3: Backend Development →

Happy coding! 🚀

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.