Mastering the two fundamental pillars of data flow and reactivity in React: immutable external inputs (props) and reactive internal memory (state). Learn the useState hook, batching mechanics, immutable state updates, lifting state up, and bidirectional component communication.
In React, Props (short for properties) are the mechanism through which parent components transmit data downward into child components. They represent the configuration arguments or parameters supplied to a component function.
Think of a React component as a pure JavaScript function:
function UserBadge(props) { return <div>{props.name}</div>; }
Just as a regular function receives parameters to determine its output without altering the caller's arguments, a React component receives props from its parent. Props define what the component should display or how it should behave.
props.username = "Jane"; // ❌ STRICT VIOLATION: Throws errors or breaks React reconciliation!
Props are passed into components using JSX attribute syntax. Any valid JavaScript data type can be transmitted as a prop: strings, numbers, booleans, arrays, objects, callback functions, and even other JSX elements.
Strings can be passed with quotes. All non-string values (numbers, booleans, objects, functions) must be wrapped in curly braces:
export default function ParentComponent() {
function handleSelect(id) {
console.log("Selected user:", id);
}
return (
<UserCard
username="ariv_dev" // String
reputation={4850} // Number
isStaffMember={true} // Boolean
skills={["React", "TS", "Node"]} // Array
profile={{ role: "Architect" }} // Object
onSelect={() => handleSelect(101)} // Callback Function
/>
);
}
React passes all JSX attributes as properties on a single JavaScript object called props:
function UserCard(props) {
return <h3>{props.username} ({props.reputation})</h3>;
}
// Direct parameter destructuring with default fallbacks
function UserCard({ username, reputation, isStaffMember }) {
return (
<div>
<h3>{username}</h3>
<span>Score: {reputation}</span>
{isStaffMember && <span>Staff</span>}
</div>
);
}
Robust components anticipate missing or malformed inputs. React provides two foundational mechanisms to ensure components receive valid data: default prop fallbacks and runtime type verification.
In modern functional React, default props are defined directly using JavaScript ES6 default parameter assignment. This provides clean, zero-overhead fallbacks when a parent omits a prop:
export function AlertBadge({
message = "Notification received",
variant = "info",
dismissible = false
}) {
return (
<div className={`alert-badge badge-${variant}`}>
<span>{message}</span>
{dismissible && <button>×</button>}
</div>
);
}
prop-types
Before TypeScript became pervasive, the official prop-types library was React's primary mechanism for verifying that components receive props of the expected types. In development mode, React logs descriptive console warnings whenever invalid types are detected:
import PropTypes from "prop-types";
function MetricCard({ title, value, isTrendingUp, onRefresh }) {
return (
<div className="metric-box">
<h4>{title}</h4>
<p>{value}</p>
<span>{isTrendingUp ? "Positive" : "Negative"}</span>
<button onClick={onRefresh}>Sync</button>
</div>
);
}
// Defining the runtime PropTypes contract
MetricCard.propTypes = {
title: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
isTrendingUp: PropTypes.bool,
onRefresh: PropTypes.func.isRequired
};
// Optional defaultProps definition (legacy pattern, still supported)
MetricCard.defaultProps = {
isTrendingUp: true
};
While props are inputs passed in from the outside, State is a component's personal, private, and internal memory. State represents data that can change over time as a user interacts with the application.
Consider this intuitive attempt using a plain JavaScript variable:
// ❌ BROKEN: Regular variable fails to trigger UI re-renders!
function Counter() {
let count = 0;
function handleClick() {
count = count + 1;
console.log("Count is now:", count); // Variable increments in console...
}
// ...but React NEVER re-renders this JSX on the screen!
return <button onClick={handleClick}>Clicked {count} times</button>;
}
Regular local variables fail for two reasons:
1. Local variables do not persist between renders: When React re-executes Counter(), it initializes let count = 0 afresh from the beginning.
2. Mutations do not trigger renders: Changing a local variable does not notify React that it needs to reconcile the Virtual DOM and update the browser display.
React provides the useState Hook to supply components with persistent state variables and an updater function that automatically schedules a re-render whenever called!
Understanding the exact distinction between Props and State is one of the most critical conceptual milestones in React engineering:
| Dimension | Props (Properties) | State |
|---|---|---|
| Origin & Ownership | Passed into the component from its parent | Created, declared, and owned inside the component |
| Mutability | Strictly Read-Only (Immutable) | Mutable via its updater function (setState) |
| Who Can Change It? | Only the parent component that owns the data | The component itself using its updater function |
| Purpose | Configures and customizes child components | Tracks dynamic user interaction and time-variant data |
| Re-render Trigger | Child re-renders when parent passes new prop values | Component re-renders when setState() is called |
| Analogy | Function arguments passed into a subroutine | Internal local variables stored in function memory |
The useState Hook is a built-in React function that lets functional components retain stateful values across renders.
import { useState } from "react";
// The Hook returns an array with exactly two elements:
// [0]: The current state snapshot
// [1]: The state setter function that triggers re-renders
const [count, setCount] = useState(0);
If the initial state requires an expensive computation (e.g., parsing a large JSON string or reading from localStorage), pass an initializer function rather than computing the value directly:
// Efficient: The callback runs ONCE on the initial mount only!
const [authToken, setAuthToken] = useState(() => {
return localStorage.getItem("app_auth_token") || "guest";
});
State updates in React do not immediately mutate the current variable in-place. Instead, calling a setter schedules a new render pass. React 18 automatically batches multiple state updates within events to maximize rendering performance.
Consider what happens when trying to increment a counter 3 times in one event handler:
function handleTripleIncrement() {
// If count is currently 0:
setCount(count + 1); // Schedules count to become 0 + 1 = 1
setCount(count + 1); // Still reads snapshot count = 0! Schedules 0 + 1 = 1
setCount(count + 1); // Still reads snapshot count = 0! Schedules 0 + 1 = 1
// RESULT: Count only increments to 1, NOT 3!
}
When the new state depends on the previous state value, always pass an updater callback function to setState. React queues each callback and supplies the fresh, calculated intermediate value:
function handleTripleIncrement() {
// Each call receives the pending state from the previous queue entry
setCount(prev => prev + 1); // prev is 0 => returns 1
setCount(prev => prev + 1); // prev is 1 => returns 2
setCount(prev => prev + 1); // prev is 2 => returns 3
// RESULT: Count correctly increments by 3!
}
In production applications, state is rarely limited to single numbers. React handles both multiple autonomous state hooks and complex data structures (objects and arrays).
Best practice: If pieces of state change independently, use separate useState calls. If states always change together (like coordinates x and y, or form fields), group them into an object:
import { useState } from "react";
export function RegistrationForm() {
const [formData, setFormData] = useState({
username: "",
email: "",
newsletter: true
});
function handleChange(event) {
const { name, value, type, checked } = event.target;
// IMMUTABLE UPDATE: Never mutate formData directly!
// Copy existing fields via spread operator (...) and override the target field
setFormData(prev => ({
...prev,
[name]: type === "checkbox" ? checked : value
}));
}
return (
<form>
<input name="username" value={formData.username} onChange={handleChange} />
<input name="email" value={formData.email} onChange={handleChange} />
<input name="newsletter" type="checkbox" checked={formData.newsletter} onChange={handleChange} />
</form>
);
}
Never use mutating array methods like push(), pop(), splice(), or sort() on state arrays. Instead, use non-mutating methods that return brand-new array references:
| Operation | Mutating (Avoid in React ❌) | Immutable Replacement (Preferred in React ✅) |
|---|---|---|
| Adding an item | arr.push(newItem) |
[...arr, newItem] (Spread syntax) |
| Removing an item | arr.splice(index, 1) |
arr.filter(item => item.id !== targetId) |
| Updating an item | arr[index].name = "Updated" |
arr.map(item => item.id === id ? { ...item, name: "Updated" } : item) |
| Sorting an array | arr.sort() |
[...arr].sort() (Copy first, then sort) |
In React, data flows downward, but sibling components often need to coordinate or reflect the same changing data.
When two or more sibling components need access to the same state, you lift the state up to their closest common parent component.
1. The parent holds the state via useState.
2. The parent passes the current state value down to siblings as props.
3. The parent passes an updater callback function down to any child that needs to request a state change.
onClick={() => onFilterChange("active")}), which executes inside the parent and triggers a state update in the parent.
Below is a fully functional, production-structured shopping cart component showcasing props passing, default props, useState, functional state updates, array immutability, and lifting state up:
import { useState } from "react";
// Child Component 1: Product Row with Event Callbacks and Default Props
function CartItem({ item, onIncrement, onDecrement, onRemove, currency = "USD" }) {
const lineTotal = item.price * item.quantity;
return (
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 0",
borderBottom: "1px solid #e2e8f0"
}}>
<div>
<strong style={{ color: "#0f172a" }}>{item.name}</strong>
<div style={{ fontSize: "12px", color: "#64748b" }}>
{currency} {item.price.toFixed(2)} each
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<button onClick={() => onDecrement(item.id)} style={btnStyle}>-</button>
<span style={{ fontWeight: 600, minWidth: "24px", textAlign: "center" }}>{item.quantity}</span>
<button onClick={() => onIncrement(item.id)} style={btnStyle}>+</button>
<span style={{ fontWeight: 700, minWidth: "70px", textAlign: "right" }}>
{currency} {lineTotal.toFixed(2)}
</span>
<button onClick={() => onRemove(item.id)} style={{ ...btnStyle, color: "#ef4444" }}>×</button>
</div>
</div>
);
}
// Child Component 2: Summary Card Displaying Lifted State
function CartSummary({ totalAmount, itemCount, onClearCart }) {
return (
<div style={{ background: "#f8fafc", padding: "16px", borderRadius: "8px", marginTop: "16px" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "8px" }}>
<span>Total Units:</span>
<strong>{itemCount}</strong>
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "16px", marginBottom: "12px" }}>
<span>Grand Total:</span>
<strong style={{ color: "#0284c7" }}>USD {totalAmount.toFixed(2)}</strong>
</div>
<button onClick={onClearCart} style={{ ...btnStyle, width: "100%", padding: "8px" }}>
Clear Shopping Cart
</button>
</div>
);
}
// Parent Component: Holds and Lifts State
export default function ShoppingCartApp() {
const [cart, setCart] = useState([
{ id: 1, name: "React System Design Course", price: 49.99, quantity: 1 },
{ id: 2, name: "TypeScript Deep Dive Handbook", price: 29.99, quantity: 2 }
]);
// Handler: Functional State Update with Array Map
function handleIncrement(id) {
setCart(prev =>
prev.map(item => item.id === id ? { ...item, quantity: item.quantity + 1 } : item)
);
}
// Handler: Decrement with Lower Bound Guard
function handleDecrement(id) {
setCart(prev =>
prev.map(item =>
item.id === id && item.quantity > 1
? { ...item, quantity: item.quantity - 1 }
: item
)
);
}
// Handler: Immutable Removal with Array Filter
function handleRemove(id) {
setCart(prev => prev.filter(item => item.id !== id));
}
// Computed Derived State (No separate state variable needed!)
const totalAmount = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0);
return (
<div style={{ maxWidth: "560px", margin: "24px auto", border: "1px solid #e2e8f0", borderRadius: "12px", padding: "24px" }}>
<h2 style={{ marginBottom: "16px", color: "#0f172a" }}>DevAscent Academy Store</h2>
{cart.length === 0 ? (
<p style={{ color: "#64748b" }}>Your cart is completely empty.</p>
) : (
cart.map(item => (
<CartItem
key={item.id}
item={item}
onIncrement={handleIncrement}
onDecrement={handleDecrement}
onRemove={handleRemove}
/>
))
)}
{cart.length > 0 && (
<CartSummary
totalAmount={totalAmount}
itemCount={totalItems}
onClearCart={() => setCart([])}
/>
)}
</div>
);
}
const btnStyle = {
background: "#ffffff",
border: "1px solid #cbd5e1",
borderRadius: "6px",
padding: "4px 10px",
cursor: "pointer",
fontWeight: 600
};
Answer: Props are external, read-only parameters passed from a parent component to configure child components, obeying strict unidirectional data flow and immutability. In contrast, State is an internal, private data store managed within the component itself using hooks (e.g., useState). While props cannot be modified by the receiving component, state can be updated via its setter function, which triggers a reconciliation pass and updates the DOM.
state.count = 5) fail to update the user interface?
Answer: React detects updates through reference checks in its reconciliation pipeline. Direct variable mutation alters memory values behind React's back without invoking the state updater function. As a result, React is never notified that state has changed, no re-render is scheduled, and the Virtual DOM does not update the browser interface. Always invoke the updater function (e.g., setCount(5)) to notify React.
setCount(prev => prev + 1))?
Answer: Functional state updates are mandatory whenever the next state depends on the previous state. Because React batches multiple state updates together and executes them asynchronously, reading the state variable directly from the current render closure can produce stale snapshot values. Passing an updater callback prev => prev + 1 ensures that React supplies the most up-to-date pending state from the update queue.
Answer: "Lifting state up" refers to relocating a piece of state from a local child component to the nearest common ancestor of two or more sibling components that both need access to that data. It solves the problem of keeping sibling components in sync under React's unidirectional data flow: the parent owns the single source of truth, distributes the data down as props, and receives change requests via callback functions.
Answer: Avoid storing redundant or calculated values in state! If a value can be computed or derived from existing props or state during the render cycle (such as const total = items.reduce(...)), compute it directly in the component body. Storing derived state in separate useState variables introduces synchronization bugs, requires redundant effects, and adds unnecessary render overhead.
defaultProps object in modern React?
Answer: Modern functional components are plain JavaScript functions. Using standard ES6 parameter defaults (e.g., function Card({ title = "Default" })) is standard JavaScript syntax, eliminates extra library baggage, integrates seamlessly with TypeScript type inference, and avoids the runtime overhead associated with the legacy Component.defaultProps pattern, which the React core team has formally slated for deprecation.
useState; updating state schedules an automatic UI re-render.setVal(prev => prev + 1) to avoid stale closure state bugs.{ ...prev, key: value }) or immutable methods (filter, map, concat).