You're Not Ready For React - 3/4

The Moment of Truth: Meeting React

You’ve built your own mini-framework. You’ve wrestled with component abstractions, implemented a basic virtual DOM, and created a state management system. You understand the fundamental problems that modern frontend frameworks solve because you’ve felt the pain firsthand and built solutions yourself.

Now it’s time to meet the real deal.

React isn’t magic—it’s engineering. Every feature you’ll encounter today exists to solve a specific problem that you’ve already experienced. The difference is that React’s solutions are battle-tested, highly optimized, and refined by millions of developers over nearly a decade.

Today, we’re going to rebuild our user management application using actual React, and you’ll see exactly how your hard-won understanding translates to production-ready code.

Setting Up React: The Modern Way

First, let’s create a proper React development environment. We’ll use Vite because it’s fast, modern, and doesn’t hide what’s happening behind layers of abstraction:

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

This gives us a React application with TypeScript support, hot module reloading, and a development server. Much better than writing everything from scratch, but now you understand what’s happening underneath.

JSX: The Syntax That Changes Everything

Remember our painful h('div', {}, ...) function calls? React solves this with JSX—a syntax extension that lets you write HTML-like code directly in JavaScript:

// Instead of this nightmare:
const element = h(
  "div",
  { className: "user-card", id: user.id },
  h("h2", {}, user.name),
  h("p", {}, user.email)
);

// You write this beautiful code:
const element = (
  <div className="user-card" id={user.id}>
    <h2>{user.name}</h2>
    <p>{user.email}</p>
  </div>
);

JSX isn’t a template language—it’s syntactic sugar that compiles to function calls. Under the hood, your JSX becomes React.createElement calls, which create the virtual DOM elements we implemented in our mini-framework.

Let’s build our first React component:

// UserCard.tsx
interface User {
  id: number;
  name: string;
  username: string;
  email: string;
}

interface UserCardProps {
  user: User;
  onEdit: (user: User) => void;
  onDelete: (user: User) => void;
}

function UserCard({ user, onEdit, onDelete }: UserCardProps) {
  const handleEdit = () => onEdit(user);
  const handleDelete = () => {
    if (window.confirm(`Delete ${user.name}?`)) {
      onDelete(user);
    }
  };

  return (
    <div className="user-card">
      <div className="user-avatar">{user.name.charAt(0).toUpperCase()}</div>
      <h3>{user.name}</h3>
      <p className="username">@{user.username}</p>
      <p className="email">{user.email}</p>
      <div className="user-actions">
        <button onClick={handleEdit} className="edit-btn">
          Edit
        </button>
        <button onClick={handleDelete} className="delete-btn">
          Delete
        </button>
      </div>
    </div>
  );
}

export default UserCard;

Look how clean this is compared to our manual DOM manipulation! The JSX clearly shows the structure, event handlers are attached declaratively, and TypeScript ensures we don’t make mistakes with props.

Hooks: The State Revolution

React Hooks are the modern way to handle state and side effects in functional components. They solve many of the problems we struggled with in our mini-framework:

// App.tsx
import { useState, useEffect } from "react";
import UserCard from "./UserCard";

interface User {
  id: number;
  name: string;
  username: string;
  email: string;
}

function App() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // useEffect replaces our manual lifecycle management
  useEffect(() => {
    loadUsers();
  }, []); // Empty dependency array = run once on mount

  const loadUsers = async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/users"
      );

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const userData: User[] = await response.json();
      setUsers(userData);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to load users");
    } finally {
      setLoading(false);
    }
  };

  const handleEditUser = (user: User) => {
    const newName = window.prompt("Enter new name:", user.name);
    if (newName && newName !== user.name) {
      setUsers((prevUsers) =>
        prevUsers.map((u) => (u.id === user.id ? { ...u, name: newName } : u))
      );
    }
  };

  const handleDeleteUser = (user: User) => {
    setUsers((prevUsers) => prevUsers.filter((u) => u.id !== user.id));
  };

  const clearUsers = () => {
    if (window.confirm("Clear all users?")) {
      setUsers([]);
    }
  };

  if (loading) {
    return (
      <div className="app">
        <div className="loading-state">
          <div className="spinner"></div>
          <p>Loading users from API...</p>
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="app">
        <div className="error-state">
          <h2>Oops! Something went wrong</h2>
          <p>{error}</p>
          <button onClick={loadUsers}>Try Again</button>
        </div>
      </div>
    );
  }

  return (
    <div className="app">
      <header className="app-header">
        <h1>User Management</h1>
        <div className="header-actions">
          <button onClick={loadUsers} disabled={loading}>
            Refresh Users
          </button>
          <button onClick={clearUsers} disabled={users.length === 0}>
            Clear All
          </button>
        </div>
      </header>

      <main className="app-main">
        {users.length === 0 ? (
          <div className="empty-state">
            <h2>No users found</h2>
            <p>Click "Refresh Users" to fetch some data</p>
          </div>
        ) : (
          <div className="users-grid">
            {users.map((user) => (
              <UserCard
                key={user.id}
                user={user}
                onEdit={handleEditUser}
                onDelete={handleDeleteUser}
              />
            ))}
          </div>
        )}
      </main>
    </div>
  );
}

export default App;

Notice how much simpler this is than our mini-framework:

  • State management: useState gives us reactive state without manual store subscriptions
  • Side effects: useEffect handles API calls and cleanup automatically
  • Performance: React handles virtual DOM diffing and optimization
  • Type safety: TypeScript ensures we don’t make mistakes with state and props

The Power of Composition

React’s component model shines when building complex UIs from simple pieces. Let’s refactor our app to demonstrate composition:

// components/LoadingSpinner.tsx
interface LoadingSpinnerProps {
  message?: string;
}

function LoadingSpinner({ message = "Loading..." }: LoadingSpinnerProps) {
  return (
    <div className="loading-state">
      <div className="spinner"></div>
      <p>{message}</p>
    </div>
  );
}

export default LoadingSpinner;
// components/ErrorMessage.tsx
interface ErrorMessageProps {
  error: string;
  onRetry?: () => void;
}

function ErrorMessage({ error, onRetry }: ErrorMessageProps) {
  return (
    <div className="error-state">
      <h2>Oops! Something went wrong</h2>
      <p>{error}</p>
      {onRetry && <button onClick={onRetry}>Try Again</button>}
    </div>
  );
}

export default ErrorMessage;
// components/EmptyState.tsx
interface EmptyStateProps {
  title: string;
  description: string;
  action?: {
    label: string;
    onClick: () => void;
  };
}

function EmptyState({ title, description, action }: EmptyStateProps) {
  return (
    <div className="empty-state">
      <h2>{title}</h2>
      <p>{description}</p>
      {action && <button onClick={action.onClick}>{action.label}</button>}
    </div>
  );
}

export default EmptyState;

Now our main App component becomes much cleaner:

// App.tsx (refactored)
import { useState, useEffect } from "react";
import UserCard from "./components/UserCard";
import LoadingSpinner from "./components/LoadingSpinner";
import ErrorMessage from "./components/ErrorMessage";
import EmptyState from "./components/EmptyState";

function App() {
  // ... state management code stays the same ...

  if (loading) {
    return (
      <div className="app">
        <LoadingSpinner message="Loading users from API..." />
      </div>
    );
  }

  if (error) {
    return (
      <div className="app">
        <ErrorMessage error={error} onRetry={loadUsers} />
      </div>
    );
  }

  return (
    <div className="app">
      <header className="app-header">
        <h1>User Management</h1>
        <div className="header-actions">
          <button onClick={loadUsers} disabled={loading}>
            Refresh Users
          </button>
          <button onClick={clearUsers} disabled={users.length === 0}>
            Clear All
          </button>
        </div>
      </header>

      <main className="app-main">
        {users.length === 0 ? (
          <EmptyState
            title="No users found"
            description="Click 'Refresh Users' to fetch some data"
            action={{
              label: "Load Users",
              onClick: loadUsers,
            }}
          />
        ) : (
          <div className="users-grid">
            {users.map((user) => (
              <UserCard
                key={user.id}
                user={user}
                onEdit={handleEditUser}
                onDelete={handleDeleteUser}
              />
            ))}
          </div>
        )}
      </main>
    </div>
  );
}

export default App;

Custom Hooks: Extracting Logic

One of React’s most powerful features is custom hooks—a way to extract and reuse stateful logic. Let’s create a custom hook for our user management logic:

// hooks/useUsers.ts
import { useState, useEffect } from "react";

interface User {
  id: number;
  name: string;
  username: string;
  email: string;
}

interface UseUsersReturn {
  users: User[];
  loading: boolean;
  error: string | null;
  loadUsers: () => Promise<void>;
  addUser: (user: User) => void;
  updateUser: (id: number, updates: Partial<User>) => void;
  deleteUser: (id: number) => void;
  clearUsers: () => void;
}

function useUsers(): UseUsersReturn {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const loadUsers = async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/users"
      );

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const userData: User[] = await response.json();
      setUsers(userData);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to load users");
    } finally {
      setLoading(false);
    }
  };

  const addUser = (user: User) => {
    setUsers((prevUsers) => [...prevUsers, user]);
  };

  const updateUser = (id: number, updates: Partial<User>) => {
    setUsers((prevUsers) =>
      prevUsers.map((user) => (user.id === id ? { ...user, ...updates } : user))
    );
  };

  const deleteUser = (id: number) => {
    setUsers((prevUsers) => prevUsers.filter((user) => user.id !== id));
  };

  const clearUsers = () => {
    setUsers([]);
    setError(null);
  };

  // Load users on mount
  useEffect(() => {
    loadUsers();
  }, []);

  return {
    users,
    loading,
    error,
    loadUsers,
    addUser,
    updateUser,
    deleteUser,
    clearUsers,
  };
}

export default useUsers;

Now our App component becomes incredibly simple:

// App.tsx (with custom hook)
import UserCard from "./components/UserCard";
import LoadingSpinner from "./components/LoadingSpinner";
import ErrorMessage from "./components/ErrorMessage";
import EmptyState from "./components/EmptyState";
import useUsers from "./hooks/useUsers";

function App() {
  const {
    users,
    loading,
    error,
    loadUsers,
    updateUser,
    deleteUser,
    clearUsers,
  } = useUsers();

  const handleEditUser = (user: User) => {
    const newName = window.prompt("Enter new name:", user.name);
    if (newName && newName !== user.name) {
      updateUser(user.id, { name: newName });
    }
  };

  const handleDeleteUser = (user: User) => {
    deleteUser(user.id);
  };

  const handleClearUsers = () => {
    if (window.confirm("Clear all users?")) {
      clearUsers();
    }
  };

  if (loading) {
    return (
      <div className="app">
        <LoadingSpinner message="Loading users from API..." />
      </div>
    );
  }

  if (error) {
    return (
      <div className="app">
        <ErrorMessage error={error} onRetry={loadUsers} />
      </div>
    );
  }

  return (
    <div className="app">
      <header className="app-header">
        <h1>User Management</h1>
        <div className="header-actions">
          <button onClick={loadUsers}>Refresh Users</button>
          <button onClick={handleClearUsers} disabled={users.length === 0}>
            Clear All
          </button>
        </div>
      </header>

      <main className="app-main">
        {users.length === 0 ? (
          <EmptyState
            title="No users found"
            description="Click 'Refresh Users' to fetch some data"
            action={{ label: "Load Users", onClick: loadUsers }}
          />
        ) : (
          <div className="users-grid">
            {users.map((user) => (
              <UserCard
                key={user.id}
                user={user}
                onEdit={handleEditUser}
                onDelete={handleDeleteUser}
              />
            ))}
          </div>
        )}
      </main>
    </div>
  );
}

export default App;

Advanced State Patterns

For more complex applications, React provides additional tools for state management:

// hooks/useUsersWithReducer.ts - Using useReducer for complex state logic
import { useReducer, useEffect } from "react";

interface User {
  id: number;
  name: string;
  username: string;
  email: string;
}

interface UsersState {
  users: User[];
  loading: boolean;
  error: string | null;
  filter: string;
  sortBy: "name" | "email" | "username";
}

type UsersAction =
  | { type: "LOAD_START" }
  | { type: "LOAD_SUCCESS"; payload: User[] }
  | { type: "LOAD_ERROR"; payload: string }
  | { type: "ADD_USER"; payload: User }
  | { type: "UPDATE_USER"; payload: { id: number; updates: Partial<User> } }
  | { type: "DELETE_USER"; payload: number }
  | { type: "CLEAR_USERS" }
  | { type: "SET_FILTER"; payload: string }
  | { type: "SET_SORT"; payload: "name" | "email" | "username" };

const initialState: UsersState = {
  users: [],
  loading: false,
  error: null,
  filter: "",
  sortBy: "name",
};

function usersReducer(state: UsersState, action: UsersAction): UsersState {
  switch (action.type) {
    case "LOAD_START":
      return { ...state, loading: true, error: null };

    case "LOAD_SUCCESS":
      return { ...state, loading: false, users: action.payload, error: null };

    case "LOAD_ERROR":
      return { ...state, loading: false, error: action.payload };

    case "ADD_USER":
      return { ...state, users: [...state.users, action.payload] };

    case "UPDATE_USER":
      return {
        ...state,
        users: state.users.map((user) =>
          user.id === action.payload.id
            ? { ...user, ...action.payload.updates }
            : user
        ),
      };

    case "DELETE_USER":
      return {
        ...state,
        users: state.users.filter((user) => user.id !== action.payload),
      };

    case "CLEAR_USERS":
      return { ...state, users: [], error: null };

    case "SET_FILTER":
      return { ...state, filter: action.payload };

    case "SET_SORT":
      return { ...state, sortBy: action.payload };

    default:
      return state;
  }
}

function useUsersWithReducer() {
  const [state, dispatch] = useReducer(usersReducer, initialState);

  const loadUsers = async () => {
    dispatch({ type: "LOAD_START" });

    try {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/users"
      );

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const userData: User[] = await response.json();
      dispatch({ type: "LOAD_SUCCESS", payload: userData });
    } catch (err) {
      dispatch({
        type: "LOAD_ERROR",
        payload: err instanceof Error ? err.message : "Failed to load users",
      });
    }
  };

  // Computed values (derived state)
  const filteredAndSortedUsers = state.users
    .filter(
      (user) =>
        user.name.toLowerCase().includes(state.filter.toLowerCase()) ||
        user.email.toLowerCase().includes(state.filter.toLowerCase()) ||
        user.username.toLowerCase().includes(state.filter.toLowerCase())
    )
    .sort((a, b) => {
      const aValue = a[state.sortBy].toLowerCase();
      const bValue = b[state.sortBy].toLowerCase();
      return aValue.localeCompare(bValue);
    });

  useEffect(() => {
    loadUsers();
  }, []);

  return {
    ...state,
    filteredUsers: filteredAndSortedUsers,
    dispatch,
    loadUsers,
  };
}

export default useUsersWithReducer;

Performance Optimization

React provides several tools for optimizing performance, which become important as your applications grow:

// components/OptimizedUserCard.tsx
import React, { memo } from "react";

interface User {
  id: number;
  name: string;
  username: string;
  email: string;
}

interface UserCardProps {
  user: User;
  onEdit: (user: User) => void;
  onDelete: (user: User) => void;
}

// memo prevents re-rendering when props haven't changed
const OptimizedUserCard = memo(function UserCard({
  user,
  onEdit,
  onDelete,
}: UserCardProps) {
  // useMemo for expensive calculations
  const displayName = useMemo(() => {
    return `${user.name} (@${user.username})`;
  }, [user.name, user.username]);

  // useCallback to prevent function recreation on every render
  const handleEdit = useCallback(() => {
    onEdit(user);
  }, [user, onEdit]);

  const handleDelete = useCallback(() => {
    if (window.confirm(`Delete ${user.name}?`)) {
      onDelete(user);
    }
  }, [user, onDelete]);

  return (
    <div className="user-card">
      <div className="user-avatar">{user.name.charAt(0).toUpperCase()}</div>
      <h3>{displayName}</h3>
      <p className="email">{user.email}</p>
      <div className="user-actions">
        <button onClick={handleEdit} className="edit-btn">
          Edit
        </button>
        <button onClick={handleDelete} className="delete-btn">
          Delete
        </button>
      </div>
    </div>
  );
});

export default OptimizedUserCard;

What You’ve Gained

Compare this React code to our vanilla DOM manipulation from the first article:

Less Code: What took hundreds of lines of manual DOM manipulation is now clean, declarative components.

Better Performance: React’s virtual DOM handles optimization automatically.

Type Safety: TypeScript integration catches errors at compile time.

Reusability: Components can be composed and reused throughout your application.

Maintainability: Clear separation of concerns and predictable data flow.

Developer Experience: Hot reloading, debugging tools, and excellent IDE support.

The Foundation You’ve Built

You now understand React at a fundamental level because you’ve built the concepts yourself:

  • Components are abstractions that encapsulate rendering logic
  • JSX is syntactic sugar for creating virtual DOM elements
  • Hooks manage state and side effects in functional components
  • Virtual DOM optimizes rendering by minimizing DOM operations
  • Props and state create predictable data flow
  • Custom hooks extract and share stateful logic

But we’re not done yet. React’s true power emerges when building complex, real-world applications with advanced patterns, performance optimizations, and architectural decisions.

In our final article, we’ll explore advanced React patterns, learn when to use (and when to avoid) complex solutions, and understand how to architect scalable React applications that don’t collapse under their own weight.

You’ve mastered the fundamentals. Now let’s make you dangerous.