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.