Skip to content

React Fundamentals for Professionals

Aman Kumar Singh8 min read
Part 1 of 50From the React & Next.js Complete Guide series
React Fundamentals for Professionals — article by Aman Kumar Singh

I've reviewed a lot of React pull requests over the years, and the bugs rarely come from exotic edge cases. They come from a shaky grip on the fundamentals: what a component actually is, when React decides to re-render, and why "it works on my machine" doesn't mean the mental model is correct. Senior engineers get away with this for a while because the framework is forgiving. Then the app grows, and the cracks show up as flicker, stale data, or a component tree that re-renders three times more than it needs to.

This article opens the React & Next.js Complete Guide, a series that goes from first principles to production Next.js apps: hooks, App Router, Server Components, state management, forms, testing, and deployment. Before any of that, we need the fundamentals to be solid, because every advanced pattern in this series builds directly on top of them.

I'm going to treat you as someone who already knows JSX exists and has shipped components before. This isn't a syntax tutorial. My goal is making sure the mental model underneath that syntax is accurate, because an inaccurate mental model is where most production React bugs are born.

Components are functions, not templates

The single most useful reframe for React is this: a component is a function that takes props and returns a description of UI. Not a template that gets "filled in," not a class with lifecycle magic. A plain function, called by React, that returns a value.

type UserCardProps = {
  name: string;
  email: string;
  isActive: boolean;
};

function UserCard({ name, email, isActive }: UserCardProps) {
  return (
    <div className="rounded-lg border p-4">
      <h3 className="font-semibold">{name}</h3>
      <p className="text-sm text-gray-500">{email}</p>
      <span className={isActive ? "text-green-600" : "text-gray-400"}>
        {isActive ? "Active" : "Inactive"}
      </span>
    </div>
  );
}

Why does this framing matter in practice? Because it explains a huge amount of React's behavior. If UserCard is called again with the same props, and it's a pure function, it should return the same output. That's the entire foundation for memoization (React.memo, useMemo), for testing components as functions, and for reasoning about "what will this render as" without running the app in your head like a state machine.

The pitfall I see most often is treating the render function as a place to do work with side effects: fetching data directly in the body, mutating a variable outside the function, logging on every call. React may call your component multiple times for a single commit (React 18's strict mode intentionally double-invokes components in development to catch this), and it may call it and then throw the result away without committing to the DOM. If your render function isn't pure, that inconsistency turns into bugs that are miserable to reproduce.

Props flow down, and that's not a limitation

Props are read-only inputs to a component. Data flows one direction: parent to child. A child can't reach up and mutate its parent's state directly; it can only call a function the parent handed it as a prop.

type TeamListProps = {
  members: { id: string; name: string }[];
  onRemove: (id: string) => void;
};

function TeamList({ members, onRemove }: TeamListProps) {
  return (
    <ul>
      {members.map((member) => (
        <li key={member.id}>
          {member.name}
          <button onClick={() => onRemove(member.id)}>Remove</button>
        </li>
      ))}
    </ul>
  );
}

New engineers sometimes treat one-way data flow as a restriction to work around, usually with some form of global mutable state or a ref that gets poked from unexpected places. I'd push back on that instinct. One-way flow is what makes a component tree traceable. When a bug report says "the user's name is wrong on this screen," you can walk up the tree from where it's rendered to where it originates, because the data can only have come from a prop, which can only have come from its own parent's state or props. Break that discipline with shared mutable objects or ref-based side channels, and you've traded a few minutes of useState boilerplate for hours of "where did this value even come from."

The real skill is deciding where state should live so the flow stays short. State that's needed by two siblings belongs in their common parent. State that's needed across the whole app might belong in context or a store, which we'll cover properly later in this series. Don't reach for global state on day one; lift state only as far up the tree as the components that actually need it.

Re-renders: what actually triggers them

This is where a lot of "senior" React code goes wrong, because the mental model of "when does this re-render" is often just wrong. A component re-renders when:

  • Its own state changes (useState, useReducer).
  • Its parent re-renders, which by default re-renders all of its children, regardless of whether their props changed.
  • A context value it consumes changes.

That second point surprises people constantly. React does not check "did the props actually change" before re-rendering a child, unless you explicitly opt in with React.memo. A re-render is not the same thing as a DOM update; React still runs a diff and only touches the DOM where the output differs. But your component function still executes, which matters if it does expensive computation inline.

type ExpensiveListProps = {
  items: string[];
  filter: string;
};

function ExpensiveList({ items, filter }: ExpensiveListProps) {
  // This recalculates on every render, including ones caused by unrelated
  // parent state changes, unless we memoize it.
  const filtered = useMemo(
    () => items.filter((item) => item.toLowerCase().includes(filter.toLowerCase())),
    [items, filter],
  );

  return (
    <ul>
      {filtered.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}

The tradeoff with useMemo and React.memo is real: they add a comparison cost and a bit of cognitive overhead, and if you sprinkle them everywhere "just in case," you've added complexity without a proven problem. I'd default to writing plain components first. Reach for memoization when you've actually identified a re-render that's expensive, whether from a large list, a heavy computation, or a component that renders often (a live counter, a websocket-driven widget). Optimizing before you've measured a real slowdown is how codebases end up full of memoization that nobody remembers the reason for.

Keys, lists, and the identity problem

Every list you render in React needs a key, and the key's job is to tell React which array item corresponds to which element across renders, so it can preserve state and DOM nodes correctly instead of tearing everything down and rebuilding it.

// Fragile: index as key breaks when the list is reordered or filtered
{items.map((item, index) => (
  <ListItem key={index} item={item} />
))}

// Correct: a stable identifier tied to the data
{items.map((item) => (
  <ListItem key={item.id} item={item} />
))}

Using the array index as a key works fine for a static list that never reorders, never has items inserted in the middle, and never gets filtered. The moment any of those happen, index-as-key causes React to match the wrong element to the wrong data, which shows up as input fields keeping stale values, checkboxes staying checked on the wrong row, or animations firing on the wrong element. It's a subtle bug because it doesn't throw an error; the UI is just quietly wrong.

The fix is always the same: use a value that's stable and unique to the item itself, ideally a database ID. If your data genuinely doesn't have one, that's usually a sign it needs one, rather than a reason to fall back to the index.

Effects are an escape hatch, not a default tool

useEffect gets treated as the general-purpose "run some code" hook, and that's a mistake that causes a disproportionate number of production bugs. Effects exist to synchronize your component with something outside of React: a subscription, a DOM API, a timer, or fetching data that the component needs to display.

function useDocumentTitle(title: string) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = title;

    return () => {
      document.title = previousTitle;
    };
  }, [title]);
}

The cleanup function matters as much as the effect itself. Every subscription, timer, or listener you set up needs a matching teardown, or you accumulate duplicate subscriptions every time the component re-mounts (which, again, React's strict mode will intentionally trigger in development specifically to expose this class of bug).

The pitfall I see constantly in code review is using useEffect to derive state from props, when a plain calculation during render would do:

// Unnecessary effect: this state is always one render behind
function UserGreeting({ user }: { user: { firstName: string } }) {
  const [greeting, setGreeting] = useState("");

  useEffect(() => {
    setGreeting(`Welcome back, ${user.firstName}`);
  }, [user.firstName]);

  return <p>{greeting}</p>;
}

// Direct derivation: correct on every render, no extra state, no lag
function UserGreeting({ user }: { user: { firstName: string } }) {
  const greeting = `Welcome back, ${user.firstName}`;
  return <p>{greeting}</p>;
}

The effect version costs more than extra code: it renders with stale state for one cycle before the effect fires and corrects it, which shows up as a visible flicker on fast connections and a real bug on slow ones. If you can compute a value from props and state during render, do that instead of routing it through an effect and a second piece of state. Save useEffect for genuine synchronization with the world outside React: network requests, browser APIs, and third-party libraries.

Key takeaways

  • Treat components as pure functions of their props; side effects in the render body cause bugs that are hard to reproduce, especially under strict mode's double-invocation.
  • One-way data flow (props down, callbacks up) keeps a component tree traceable; don't work around it with shared mutable state or ref-based side channels.
  • A component re-renders whenever its parent re-renders, regardless of whether its own props changed, unless you opt into `React.memo`. Measure before reaching for memoization.
  • Always key lists with a stable, unique identifier from the data, not the array index, or you'll get subtle state-mismatch bugs on reorder and filter.
  • `useEffect` is for synchronizing with things outside React (subscriptions, DOM APIs, network calls), not a general-purpose place to run code or derive state you could compute directly during render.
  • Solid fundamentals here are what make the next articles in this series, hooks, App Router, Server Components, actually make sense instead of feeling like memorized rules.

Frequently asked questions

Why does my component re-render even though its props didn't change?

By default, React re-renders every child of a component whenever that component re-renders, regardless of whether the child's own props changed. Wrap the child in `React.memo` if you've measured that this re-render is expensive and worth avoiding; don't apply it preemptively everywhere.

Is it ever okay to use the array index as a key?

Yes, for lists that are static: never reordered, never filtered, never have items inserted or removed from the middle. Anything more dynamic should use a stable ID from the data, or you'll get state and DOM mismatches that are hard to trace back to their cause.

When should I use useEffect versus computing a value directly in the component?

If you can calculate the value from current props and state, do it directly in the render body or with `useMemo`. Reach for `useEffect` only when you're synchronizing with something outside React's own render cycle, like a subscription, a browser API, or a network request.

Why does my effect run twice in development?

React 18's strict mode intentionally mounts, unmounts, and remounts components in development to surface effects that don't clean up properly. If your effect breaks under this, it's missing a cleanup function, and that same bug would eventually show up in production too, just less predictably.

Where should shared state actually live in a React app?

As close to the components that need it as possible, and no further. If two siblings need the same state, lift it to their nearest common parent. Reach for context or a dedicated store only once prop drilling becomes a genuine maintenance problem, not by default.

What's the difference between a re-render and a DOM update?

A re-render means your component function runs again and returns a new element tree. React then diffs that tree against the previous one and only touches the actual DOM where something changed. A component can re-render frequently while the DOM barely updates at all, which is why re-render frequency and rendering cost aren't the same thing.

Related articles

Avoiding Unnecessary Re-Renders — Aman Kumar Singh
Using React Context the Right Way — Aman Kumar Singh
useMemo and useCallback, When They Help — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.