function Counter({ initialCount }) { // initialCount is a prop
const [count, setCount] = useState(initialCount); // count is state
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Props flow down from parent to child. State is local and triggers re-renders when updated via its setter function.
Why it matters: Understanding the difference between props and state is the foundation of React. Props are data passed in from outside; state is data the component itself owns and controls. Getting this wrong leads to broken data flow and bugs.
Real applications: A Button component receives its label as a prop (controlled by parent), while a form field's current value is stored in state (managed internally). Most components use both together.
Common mistakes: Storing props in state (causes stale values when props change), mutating state directly instead of using setState, and not knowing that state updates are async (reading state right after setting it may return the old value).
count={42} not count="42".
function Parent() {
return <Child name="Alice" age={30} />;
}
function Child({ name, age }) {
return <p>{name} is {age}</p>;
}
You can pass any JavaScript value as a prop: strings, numbers, objects, arrays, functions, or even other components.
Why it matters: Passing props is how components communicate. The parent controls the child's behavior entirely through props, keeping the app's data flowing in one direction — which makes it easier to trace and debug.
Real applications: Passing a user object to a ProfileCard, passing a list of items to a Table component, passing an onSubmit callback from a page to a Form, and passing a component as a prop to render inside a layout slot.
Common mistakes: Passing too many individual props when an object would be cleaner, forgetting to destructure and writing props.name everywhere, and not using spread carefully ({...props} passes everything including unwanted props to DOM elements).
map, filter, or Object.assign to produce a new value. This immutability rule is fundamental to React's predictable rendering model.
// Wrong — mutating directly
user.name = "Bob";
setUser(user); // same reference, no re-render
// Correct — creating a new object
setUser({ ...user, name: "Bob" }); // new reference, triggers re-render
Always create new objects or arrays when updating state. This enables React's diffing algorithm and predictable rendering behavior.
Why it matters: React uses reference equality to detect state changes. If you mutate the existing object, the reference stays the same, and React does not re-render. Immutability makes change detection reliable and renders predictable.
Real applications: Updating a list by spreading into a new array, adding a property to an object by creating a new object with spread, and removing an item with .filter() rather than .splice().
Common mistakes: Calling state.push(item) and then setting state (React doesn't see the change), directly setting state.name = 'Alice' without using the setter, and mutating nested objects (state.user.name = 'Alice' — you must spread all the way down).
function Parent() {
const [value, setValue] = useState("");
return (
<>
<Input value={value} onChange={setValue} />
<Display value={value} />
</>
);
}
This pattern keeps components in sync. The parent owns the state and passes it down along with update callbacks as props.
Why it matters: When two sibling components need to share data, neither should own the state independently. Moving state to their common parent is the React-idiomatic way to keep them in sync without an external library.
Real applications: A search input and a results list that both respond to the search query, two form steps that share form data, a filter panel and a product grid controlled by the same filter values.
Common mistakes: Lifting state too far up (makes the root component bloated), not lifting state high enough (siblings get out of sync), and lifting state when Context or a state manager would be a cleaner solution for deeply nested components.
// Prop drilling: App -> Layout -> Sidebar -> UserMenu -> Avatar
<Layout user={user}> // Layout doesn't use user
<Sidebar user={user}> // Sidebar doesn't use user
<UserMenu user={user} />
</Sidebar>
</Layout>
Solutions include React Context API, state management libraries like Redux, or component composition patterns to avoid unnecessary nesting.
Why it matters: Prop drilling makes code hard to maintain — every intermediate component must accept and forward props it doesn't use. Recognizing when it becomes a problem and knowing the right solution is an important skill.
Real applications: Passing a current user object from the App root down through multiple levels to a Navbar component, threading a theme or locale through an entire component tree, passing callbacks many levels deep.
Common mistakes: Using Context to fix all prop drilling (Context is great for global data but causes all consumers to re-render on change), not trying component composition first (can solve many cases without any state solution), and creating large Context providers with unrelated data.
children prop is a special built-in prop that automatically contains whatever JSX is placed between a component's opening and closing tags. This enables the composition pattern — you can build generic wrapper components like cards, modals, and layouts that are completely agnostic about what content they'll display. You don't declare children in the component's prop list; React passes it automatically whenever anything is nested between the tags. Any valid React content works as children — text, elements, arrays, or other components.
function Card({ children, title }) {
return (
<div className="card">
<h2>{title}</h2>
<div>{children}</div>
</div>
);
}
<Card title="Info"><p>Content here</p></Card>
The children prop enables flexible component composition by letting parents inject arbitrary content into a component's layout.
Why it matters: The children prop is what makes React components truly composable. Layout components like modals, cards, and panels become generic and reusable by accepting children instead of specifying their content directly.
Real applications: A Card component that renders any content inside its borders, a Modal that wraps any dialog content, a Layout component with a header and footer that wraps page-specific content, and a Button with flexible label via children.
Common mistakes: Not using children and instead accepting a content or label prop when children would be more flexible, not knowing you can use React.Children utilities to map or count children, and passing multiple children when a component only expects one (need to wrap in a Fragment).
undefined, the default value activates and the component renders as expected without crashing or showing empty content. This is purely standard JavaScript — no React-specific API needed — making the component easy to understand and test in isolation. Default values also serve as implicit documentation: they show exactly which props are optional and what safe fallback behaviors look like.
function Alert({ message = "Something happened", type = "info" }) {
return <div className={`alert alert-${type}`}>{message}</div>;
}
// Usage
<Alert /> // uses both defaults
<Alert message="Error!" type="danger" /> // overrides both
Default parameter values are evaluated when the prop is undefined, not when it is null.
Why it matters: Default props make components more robust when some data is optional. They clearly communicate what a component needs vs what is optional, and prevent undefined-related rendering errors.
Real applications: A pagination component with default pageSize={10}, a dialog with default isOpen={false}, a button with default variant="primary", and any reusable component that works sensibly without all props being provided.
Common mistakes: Using prop || default when 0 or false are valid values (use prop ?? default instead), not providing defaults for props that affect rendered output (blank UI is confusing), and using the legacy Component.defaultProps in modern functional component code.
import PropTypes from 'prop-types';
function User({ name, age, email }) {
return <p>{name} ({age}) — {email}</p>;
}
User.propTypes = {
name: PropTypes.string.isRequired,
age: PropTypes.number,
email: PropTypes.string.isRequired
};
For modern projects, TypeScript interfaces are generally preferred over PropTypes for static type safety.
Why it matters: PropTypes provide runtime validation that warns you in development when a component receives wrong types. TypeScript does this at compile time. Both exist to catch bugs early from passing wrong data to components.
Real applications: Validating that a required userId was passed, ensuring an array prop is actually an array, checking that a callback prop is a function, and documenting the expected data shape of a component's props.
Common mistakes: Using PropTypes in a TypeScript project (redundant), not marking required props as .isRequired, and only validating at the top level of an object without using PropTypes.shape() to validate its structure.
setTimeout, Promise callbacks, and native event handlers — eliminating cases where updates were processed one at a time. This optimization dramatically reduces unnecessary renders and keeps the UI responsive during complex interactions.
function handleClick() {
setCount(c => c + 1); // batched
setFlag(f => !f); // batched
setName("Alice"); // batched
// React renders only ONCE after all three updates
}
React 18+ automatically batches all state updates, including those in promises, timeouts, and native event handlers. Earlier versions only batched inside React event handlers.
Why it matters: Batching prevents extra re-renders when multiple state changes happen together. Understanding this helps you predict when renders happen and why you don't see multiple renders for multiple sequential setState calls in one handler.
Real applications: Updating multiple pieces of state in a single user action (e.g., setting loading=false, data=result, error=null after an API call), and understanding why your component only renders once even after three setState calls.
Common mistakes: Not knowing batching was limited to React event handlers in React 17 and below (async callbacks caused multiple renders), using flushSync without understanding that it opts out of batching (forces immediate synchronous render), and expecting to read updated state right after calling setState (the update is queued, not applied immediately).
useMemo if the computation is expensive. The rule of thumb: if a value can be derived from what you already have, don't store it in state — calculate it on the fly.
// Bad: duplicating state
const [items, setItems] = useState([]);
const [count, setCount] = useState(0); // derived from items.length
// Good: compute during render
const [items, setItems] = useState([]);
const count = items.length; // no extra state needed
Compute values during rendering when possible. Only use state for data that cannot be derived from other state or props.
Why it matters: Derived state creates two copies of truth that can get out of sync. If you calculate a value from existing state or props inside the render, React always shows the correct derived value with zero extra effort.
Real applications: Computing a cart total from an array of items (no need to store total separately), filtering a list based on a search term (compute the filtered list, don't store it), and generating display text from a status code.
Common mistakes: Storing derived values in state and manually syncing them with useEffect (fragile and error-prone), not realizing computation during render is cheap for most operations, and over-optimizing with useMemo before checking if it is actually needed.
setState(prev => prev + 1) always receives the most up-to-date accumulated state, making it safe in rapid updates, concurrent renders, and async callbacks.
// Risky — may read stale value when batched
setCount(count + 1);
setCount(count + 1); // both read same stale count!
// Safe — always gets the latest value
setCount(prev => prev + 1);
setCount(prev => prev + 1); // correctly increments twice
// Same rule for objects and arrays
setItems(prev => [...prev, newItem]);
setUser(prev => ({ ...prev, name: 'Bob' }));
Always use the functional form inside async callbacks, useEffect, or when calling the setter multiple times in one handler.
Why it matters: State updates are async and state values in a closure can be stale. The functional update form always receives the latest state, preventing bugs where multiple increments only apply once because they all read the same old value.
Real applications: A counter that increments multiple times in one click handler, updating a list by adding to the current items, and toggling a boolean value reliably regardless of when the setter runs.
Common mistakes: Using setCount(count + 1) multiple times in one handler and getting a single increment (all closures read the same stale value), not knowing the functional form exists, and using the functional form everywhere unnecessarily when the value doesn't depend on the previous state.
function Parent() {
const [selected, setSelected] = useState(null);
return (
<>
<ItemList onSelect={setSelected} />
<Detail item={selected} />
</>
);
}
function ItemList({ onSelect }) {
return items.map(item =>
<button key={item.id} onClick={() => onSelect(item)}>
{item.name}
</button>
);
}
Unidirectional flow makes apps predictable and easy to debug — you always know where state lives and how it changes.
Why it matters: React's unidirectional data flow means state always flows from parent to child via props, and children communicate back through callbacks. This makes the data path explicit and traceable, unlike two-way binding where changes can happen in multiple places.
Real applications: Every React app follows this model. A parent holds the selected item state, passes it down to a list, and the list calls a parent-provided callback when an item is selected.
Common mistakes: Trying to pass data up via props (you can only pass down — use callbacks for upward communication), getting confused about why a child can't directly change a parent's state, and using two-way data binding patterns (like Angular's ngModel) conceptually when React requires explicit callbacks.
useState — React calls it only once on the initial render and ignores it on every subsequent render. Without this, the expression inside useState() is evaluated on every render even though only the first call matters. This is especially important when the initial value comes from an expensive computation — like parsing a large JSON string, reading from localStorage, or processing a large dataset. Always use the lazy form useState(() => expensiveCalc()) when the initial value is costly to compute.
// Runs on EVERY render — slow if parsing is expensive
const [data, setData] = useState(JSON.parse(localStorage.getItem('data')));
// Lazy initializer — runs only ONCE on mount
const [data, setData] = useState(() => {
const stored = localStorage.getItem('data');
return stored ? JSON.parse(stored) : [];
});
Use the lazy initializer whenever the initial value requires computation, storage reads, or any work you don't want repeated on every render.
Why it matters: The function form of useState is only called once — on mount. If you pass a value directly, the expression is evaluated on every render even though it's only used the first time. For expensive operations, this difference matters.
Real applications: Reading from localStorage for initial state, parsing a complex initial data structure from props, generating a unique ID for the component on mount, and any expensive computation that produces the starting value.
Common mistakes: Writing useState(heavyCompute()) instead of useState(() => heavyCompute()) (the function runs on every render with the direct form), and not knowing this optimization exists at all.
function Parent() {
const [search, setSearch] = useState('');
return (
<>
{/* Sibling 1 — updates shared state */}
<SearchInput value={search} onChange={setSearch} />
{/* Sibling 2 — reads shared state */}
<ResultList filter={search} />
</>
);
}
The parent becomes the single source of truth. For deeply nested sharing, use Context API or a state management library instead.
Why it matters: Sibling components cannot directly share state. Lifting state to the common parent is the clean, React-native solution, and understanding when to lift vs when to use Context is an important architectural decision.
Real applications: A tab bar and the content area that changes based on the selected tab, a sidebar filter and the main content list that responds to filter changes, and two components that both display and modify the same user object.
Common mistakes: Lifting state too high up the tree unnecessarily (causes unrelated components to re-render), not lifting state high enough (siblings go out of sync), and not knowing when to stop lifting and use Context or Zustand instead.
// Use state — count appears in the UI
const [count, setCount] = useState(0);
// Use ref — timer ID is never displayed
const timerRef = useRef(null);
timerRef.current = setTimeout(callback, 1000);
// Use ref — track previous value without extra render
const prevValueRef = useRef(value);
useEffect(() => { prevValueRef.current = value; });
Rule of thumb: if the value needs to appear in rendered output, use useState. If it is purely internal, use useRef.
Why it matters: Both useState and useRef persist values across renders, but only useState causes a re-render when it changes. Choosing state for everything causes unnecessary re-renders; using refs for rendered values means the UI won't update.
Real applications: A timer ID stored in a ref (no need to show it), a previous value tracked in a ref for comparison, a scroll position used only for logic (not rendered), vs a counter that appears in the UI (must be state).
Common mistakes: Using state to store a timer ID or interval reference (triggers a re-render on set and clear, which is unnecessary), using a ref to store a value that should update the UI (user won't see the change), and not knowing you can store any mutable value in a ref, not just DOM elements.