DEV_NET_CORE
GET_STARTED
ReactComponents, props, state, and rendering behavior

Props flow, local state, and lifting state up

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.

Code
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.

Code
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.

Code
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:

Code
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.

Code
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.

Code
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:

Code
setCount((current) => current + 1);

This matters when queueing multiple updates:

Code
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.

Code
function Toggle() {
  const [enabled, setEnabled] = useState(false);

  return (
    <button onClick={() => setEnabled((value) => !value)}>
      {enabled ? "On" : "Off"}
    </button>
  );
}

Do not mutate state directly:

Code
user.name = "Ava";
setUser(user);

Create a new value:

Code
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:

Code
const [firstName, setFirstName] = useState("Ava");
const [lastName, setLastName] = useState("Nguyen");
const [fullName, setFullName] = useState("Ava Nguyen");

Better:

Code
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:

Code
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:

Code
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:

Code
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:

Code
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.

Code
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:

Code
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.

Code
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.

Code
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:

Code
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:

Code
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.

Code
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:

Code
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:

Code
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:

Code
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:

Code
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:

Code
<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.

Interview Practice

PreviousFunctional components and JSX compositionNext UpRerender triggers, derived state, and avoiding duplicated state