React Basics
What you'll build
By the end of this lesson you will have a small single-page app with three components: a search bar, a list of posts fetched from a public API, and a post card. The app filters posts as you type, using component state and an effect for the initial fetch. You will understand the React mental model well enough to build most front-end features without looking things up.
Concepts
JSX and components
React lets you describe UIs as components, functions that return JSX. JSX looks like HTML written inside JavaScript. Under the hood, Babel compiles it to React.createElement(...) calls, but you never write those by hand.
// A simple component, a function that returns JSX
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
// Use it like an HTML tag
function App() {
return (
<div>
<Greeting name="Priya" />
<Greeting name="Rahul" />
</div>
);
}
Rules of JSX:
- A component name must start with a capital letter.
<greeting />is an HTML tag,<Greeting />is a component., JSX must return a single root element. Wrap siblings in a<div>or a Fragment (<>...</>)., JavaScript expressions go inside{}. You can put any expression there: variables, function calls, ternaries. classis reserved in JavaScript, so useclassNamein JSX.forbecomeshtmlFor.
function StatusBadge({ isActive }) {
return (
<span className={isActive ? "badge-green" : "badge-grey"}>
{isActive ? "Active" : "Inactive"}
</span>
);
}
Props
Props (properties) are how you pass data from a parent component to a child. They are read-only, a child never modifies its own props.
function PostCard({ title, author, excerpt }) {
return (
<article className="card">
<h2>{title}</h2>
<p className="author">By {author}</p>
<p>{excerpt}</p>
</article>
);
}
function PostList({ posts }) {
return (
<div className="post-list">
{posts.map((post) => (
<PostCard
key={post.id}
title={post.title}
author={post.userId}
excerpt={post.body.slice(0, 100) + "..."}
/>
))}
</div>
);
}
The key prop is required when rendering lists. React uses it to efficiently update only the items that changed. Use a stable unique identifier (a database ID) rather than the array index, array indices change when items are inserted or removed, which causes subtle rendering bugs.
useState
useState lets a component remember a value across renders. When you call the setter function, React re-renders the component with the new value.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0); // initial value is 0
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
useState returns an array of two things: the current value and a setter function. Destructuring const [count, setCount] is the standard pattern.
Never directly mutate state: do not do count = count + 1 or myArray.push(item). Always call the setter. For arrays and objects, create a new value:
// Wrong, mutates the existing array
const [items, setItems] = useState([]);
items.push("new item"); // React does not know this changed
// Correct, creates a new array
setItems([...items, "new item"]);
useEffect
useEffect runs side effects, things that happen outside of rendering: fetching data, setting up subscriptions, interacting with browser APIs.
import { useState, useEffect } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// This runs after the component renders
let cancelled = false;
async function fetchUser() {
setLoading(true);
setError(null);
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
if (!cancelled) setUser(data);
} catch (e) {
if (!cancelled) setError(e.message);
} finally {
if (!cancelled) setLoading(false);
}
}
fetchUser();
// Cleanup function, runs when the component unmounts or userId changes
return () => { cancelled = true; };
}, [userId]); // dependency array, re-runs when userId changes
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
if (!user) return null;
return <h2>{user.name}</h2>;
}
The second argument to useEffect is the dependency array. If it is empty [], the effect runs once after the first render. If it contains values like [userId], it re-runs whenever those values change.
Hands-on
Build a post browser SPA. First scaffold the project:
npm create vite@latest post-browser -- --template react
cd post-browser
npm install
npm run dev
Replace src/App.jsx with:
import { useState, useEffect } from "react";
import "./App.css";
// ---- SearchBar ----
function SearchBar({ query, onQueryChange }) {
return (
<div className="search-bar">
<input
type="search"
placeholder="Filter posts..."
value={query}
onChange={(e) => onQueryChange(e.target.value)}
aria-label="Filter posts"
/>
</div>
);
}
// ---- PostCard ----
function PostCard({ title, body }) {
return (
<article className="card">
<h3>{title}</h3>
<p>{body.slice(0, 120)}...</p>
</article>
);
}
// ---- PostList ----
function PostList({ posts, loading, error }) {
if (loading) return <p className="status">Loading posts...</p>;
if (error) return <p className="status error">Error: {error}</p>;
if (posts.length === 0) return <p className="status">No posts match your search.</p>;
return (
<div className="post-grid">
{posts.map((post) => (
<PostCard key={post.id} title={post.title} body={post.body} />
))}
</div>
);
}
// ---- App (root) ----
export default function App() {
const [allPosts, setAllPosts] = useState([]);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function loadPosts() {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
setAllPosts(data);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}
loadPosts();
}, []); // empty array = run once on mount
const filtered = allPosts.filter(
(p) =>
p.title.toLowerCase().includes(query.toLowerCase()) ||
p.body.toLowerCase().includes(query.toLowerCase())
);
return (
<div className="app">
<header>
<h1>Post Browser</h1>
<SearchBar query={query} onQueryChange={setQuery} />
<p className="count">{filtered.length} posts</p>
</header>
<main>
<PostList posts={filtered} loading={loading} error={error} />
</main>
</div>
);
}
Replace src/App.css with:
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f7fa; color: #333; }
.app { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
header { margin-bottom: 24px; }
h1 { font-size: 2rem; margin-bottom: 12px; }
.search-bar input { width: 100%; padding: 10px 14px; font-size: 1rem; border: 1px solid #ccc; border-radius: 6px; }
.count { margin-top: 8px; color: #888; font-size: 0.875rem; }
.post-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.card { background: white; border: 1px solid #e0e0e0; border-radius: 8px; padding: 16px; }
.card h3 { margin-bottom: 8px; font-size: 1rem; text-transform: capitalize; }
.card p { font-size: 0.875rem; color: #555; line-height: 1.5; }
.status { padding: 24px 0; color: #888; }
.status.error { color: red; }
Open http://localhost:5173. You will see 100 posts from the API. Start typing in the search box, posts filter live as you type. No page reload, no extra fetch. The filtering is pure computation over the allPosts array, which is already in memory. React re-renders only the components that depend on changed state.
Common pitfalls
- Missing the
keyprop on list items. React warns about this in the console and may silently produce wrong rendering behaviour when the list order changes. Always addkeywith a unique stable value. - Calling a hook conditionally or inside a loop. React requires hooks to be called in the same order on every render. Wrapping
useStatein anifblock will cause an error. Move the conditional logic inside the hook, not around it. - Mutating state directly.
state.push(item)orstate.count++do not trigger a re-render. Always use the setter fromuseStateand create new values for arrays and objects. - Infinite useEffect loops. If your effect sets state and that state is in the dependency array, the effect will run on every render indefinitely. Think carefully about dependencies. If you see the network tab firing requests in a loop, this is the cause.
- Forgetting to handle the async cleanup in useEffect. If a component unmounts while a fetch is in flight, calling
setStateon an unmounted component is a no-op but it can hide bugs. Use acancelledflag as shown in the UserProfile example to guard all state updates.
What to try next
- Add a pagination control below the post list: show 10 posts at a time with Previous and Next buttons. Use a
pagestate variable andArray.slice()to extract the right range. - Extract the data-fetching logic into a custom hook called
usePosts. Move theallPosts,loading, anderrorstate and theuseEffectinto it, and return all three. The App component then callsconst { allPosts, loading, error } = usePosts(). - Add a "Sort by title" toggle button. Maintain a
sortAscboolean state. Before filtering, sort theallPostsarray alphabetically by title whensortAscis true. Remember thatArray.sort()mutates the original, spread first:[...allPosts].sort(...).
Prefer watching over reading?
Subscribe for free.