Overview
Props flow, local state, and lifting state up describe how data moves through a React component tree. Props are inputs passed from parent components to child components. Local state is data a component remembers between renders. Lifting state up means moving shared state to the closest common parent so multiple components can stay synchronized.
React's data model is intentionally directional: parents pass data down through props, and children communicate changes by calling callbacks passed down from parents.
function Parent() {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UserList
selectedId={selectedId}
onSelectUser={setSelectedId}
/>
);
}
This topic matters in interviews because many React bugs come from putting state in the wrong place. A candidate should understand when data should be a prop, when it should be local state, when it should be derived during render, when state should be lifted to a parent, and when context or a state library may be appropriate.
The practical goal is to keep data flow predictable: one clear owner for each piece of state, minimal duplicated state, immutable updates, and components that receive the data and event handlers they need.
Core Concepts
Props
Props are inputs to a component. A parent passes props, and the child reads them.
type UserCardProps = {
name: string;
email: string;
};
function UserCard({ name, email }: UserCardProps) {
return (
<article>
<h2>{name}</h2>
<p>{email}</p>
</article>
);
}
function UserPage() {
return <UserCard name="Ava" email="[email protected]" />;
}
Props should be treated as read-only. A child should not mutate props. If something needs to change, the component that owns the state should update it and pass the new value down.
One-Way Data Flow
React data typically flows from parent to child.
function App() {
const user = {
name: "Ava",
role: "Admin",
};
return <Profile user={user} />;
}
function Profile({ user }: { user: { name: string; role: string } }) {
return <h1>{user.name}</h1>;
}
The parent owns the data and decides what the child receives. The child renders based on those props.
Children communicate events upward by calling callback props:
function SaveButton({ onSave }: { onSave: () => void }) {
return <button onClick={onSave}>Save</button>;
}
This keeps data ownership explicit. The child does not directly modify parent state; it requests a change by calling a function.
Local State
Local state is data a component remembers between renders.
import { useState } from "react";
function ExpandablePanel({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<section>
<button onClick={() => setIsOpen((open) => !open)}>
{title}
</button>
{isOpen && <div>{children}</div>}
</section>
);
}
This state is local because only ExpandablePanel needs to know whether it is open.
Good candidates for local state:
- Input draft text.
- Whether a menu is open.
- Which tab is selected inside a self-contained widget.
- Temporary UI-only state.
- Hover or focus-related UI state when CSS is not enough.
Poor candidates for local state:
- Data that multiple sibling components need.
- Data already available from props.
- Values that can be derived from other state.
- Server data that should be cached or synchronized by a data layer.
State as a Snapshot
State values behave like snapshots for a particular render. Calling a state setter does not change the variable in the current render; it schedules a future render with a new state value.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // Still the old value for this render.
}
return <button onClick={handleClick}>{count}</button>;
}
If the next value depends on the previous value, use a functional update:
setCount((current) => current + 1);
This matters when queueing multiple updates:
setCount((current) => current + 1);
setCount((current) => current + 1);
setCount((current) => current + 1);
The result is three increments.
State Updates Trigger Rendering
When a component's state changes, React schedules a re-render of that component and its children.
function Toggle() {
const [enabled, setEnabled] = useState(false);
return (
<button onClick={() => setEnabled((value) => !value)}>
{enabled ? "On" : "Off"}
</button>
);
}
Do not mutate state directly:
user.name = "Ava";
setUser(user);
Create a new value:
setUser((current) => ({
...current,
name: "Ava",
}));
React relies on state identity to detect changes. Immutable updates make rendering predictable.
Derived Values vs Stored State
If a value can be calculated from props or state during render, it often should not be stored separately.
Avoid duplicated state:
const [firstName, setFirstName] = useState("Ava");
const [lastName, setLastName] = useState("Nguyen");
const [fullName, setFullName] = useState("Ava Nguyen");
Better:
const [firstName, setFirstName] = useState("Ava");
const [lastName, setLastName] = useState("Nguyen");
const fullName = `${firstName} ${lastName}`;
Storing derived state creates synchronization bugs. If one source updates and the derived state does not, the UI becomes inconsistent.
Store state when the value cannot be derived, or when derivation is expensive enough to memoize. Even then, prefer useMemo for expensive derived values rather than another state variable.
Choosing Where State Lives
State should live in the component that owns it. A good rule:
- If only one component needs it, keep it local.
- If a parent needs to control it, put it in the parent.
- If siblings need it, lift it to their closest common parent.
- If many distant components need it, consider context or an external store.
Example:
function SearchBox() {
const [query, setQuery] = useState("");
return (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
);
}
This is fine if the query only matters inside SearchBox.
If another component needs the query, move it up:
function SearchPage() {
const [query, setQuery] = useState("");
return (
<>
<SearchInput value={query} onChange={setQuery} />
<SearchResults query={query} />
</>
);
}
Lifting State Up
Lifting state up means moving state from a child component to a common parent so multiple components can share it.
Before lifting:
function TemperatureInput() {
const [temperature, setTemperature] = useState("");
return (
<input
value={temperature}
onChange={(event) => setTemperature(event.target.value)}
/>
);
}
If another component needs the same temperature, the state belongs higher:
function TemperatureCalculator() {
const [temperature, setTemperature] = useState("");
return (
<>
<TemperatureInput
value={temperature}
onChange={setTemperature}
/>
<TemperaturePreview temperature={temperature} />
</>
);
}
Now both children receive consistent data from the same owner.
Controlled Components
A controlled component receives its value and change handler from its parent.
function SearchInput({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<input
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
}
The parent owns the state:
function SearchPage() {
const [query, setQuery] = useState("");
return (
<>
<SearchInput value={query} onChange={setQuery} />
<SearchResults query={query} />
</>
);
}
Controlled components are predictable because the source of truth is outside the child.
Uncontrolled Local State
A component can manage its own state internally when no parent needs to coordinate it.
function ToggleDetails({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen((value) => !value)}>
{open ? "Hide" : "Show"}
</button>
{open && children}
</section>
);
}
This is simpler than forcing every parent to manage open.
A reusable component may support both controlled and uncontrolled modes, but that increases complexity. Use this pattern carefully and document it clearly.
Callback Props
Callback props let children notify parents about events.
function UserRow({
user,
onSelect,
}: {
user: User;
onSelect: (id: string) => void;
}) {
return (
<button onClick={() => onSelect(user.id)}>
{user.name}
</button>
);
}
The child does not know what selecting means. The parent decides:
function UserTable({ users }: { users: User[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
return users.map((user) => (
<UserRow
key={user.id}
user={user}
onSelect={setSelectedId}
/>
));
}
Naming matters. Use domain names when possible:
onSelectUser
onSubmitOrder
onCloseDialog
This is clearer than generic names like onClick when the child represents a domain action.
Props Drilling
Props drilling means passing props through intermediate components that do not use them, only to reach a deeply nested child.
function App() {
return <Page user={user} />;
}
function Page({ user }: { user: User }) {
return <Layout user={user} />;
}
function Layout({ user }: { user: User }) {
return <UserMenu user={user} />;
}
Props drilling is not automatically bad. A few levels can be explicit and easy to understand. It becomes a problem when many unrelated layers forward the same values, making components noisy and tightly coupled.
Solutions include:
- Component composition.
- Moving the consuming component closer to the data owner.
- Context for widely needed values.
- External state stores for complex global state.
State Colocation
State colocation means keeping state as close as possible to where it is used.
Good:
function PasswordField() {
const [visible, setVisible] = useState(false);
return (
<>
<input type={visible ? "text" : "password"} />
<button onClick={() => setVisible((value) => !value)}>
Toggle
</button>
</>
);
}
The parent does not need to know whether this one password field is visible.
State should be lifted only when sharing or coordination requires it. Over-lifting state can cause unnecessary re-renders, larger components, and less reusable children.
Avoiding Duplicated State
Duplicated state means storing the same information in multiple places.
Bad:
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
If both represent the same selection, they can become inconsistent.
Better:
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const selectedUser = users.find((user) => user.id === selectedUserId) ?? null;
Store the minimal source of truth and derive the rest during render.
Resetting State
State is tied to a component's position in the rendered tree. If a component remains in the same position, React usually preserves its state. If it is removed or rendered with a different key, state may reset.
You can intentionally reset child state with a key:
function UserEditor({ userId }: { userId: string }) {
return <EditForm key={userId} userId={userId} />;
}
This tells React that a different userId should create a fresh form.
Do not use random keys to force resets:
<EditForm key={Math.random()} />
That destroys state on every render and usually indicates a design problem.
When to Use Context Instead
Lifting state up works well for nearby components. If data is needed by many distant components, context may be a better fit.
Good context candidates:
- Current authenticated user.
- Theme.
- Locale.
- Feature flags.
- Shared app shell configuration.
Poor context candidates:
- Highly local input state.
- Rapidly changing per-row state in large lists.
- State used by only one or two nearby components.
Context is not a replacement for all props. Props remain the clearest way to pass explicit component inputs.
Common Mistakes
Common mistakes include:
- Mutating props or state directly.
- Duplicating derived values in state.
- Keeping state too low when siblings need it.
- Lifting state too high when only one component uses it.
- Using context for every shared value.
- Passing generic callbacks with unclear names.
- Forgetting that state updates are scheduled, not immediate variable changes.
- Reading stale state when multiple updates depend on previous values.
- Making a reusable component support both controlled and uncontrolled modes without a clear API.
- Passing whole objects when children only need a few fields.
Best Practices
Use these rules of thumb:
- Treat props as read-only.
- Keep state close to where it is used.
- Lift state to the closest common parent when components need to coordinate.
- Store the minimal source of truth.
- Derive values during render when possible.
- Use functional updates when the next state depends on previous state.
- Use callback props for child-to-parent communication.
- Prefer explicit props before reaching for context.
- Use context for cross-cutting values shared across distant parts of the tree.