React Fundamentals: Components, Props, and State

I explain the three mental models behind React — UI as a function of state, one-way data flow, and state colocation — through components, props, and useState.

React Fundamentals: Components, Props, and State

Most React tutorials throw a component at you on page one and call that an introduction. By the end you’ve seen a bunch of syntax, but the why is still fuzzy — why does changing a variable directly not update the screen? Why can’t a component reach up and modify its own prop? Why is everyone so insistent about keys in lists?

Three ideas unlock most of it:

  1. A component is a function: component(props, state) → JSX.
  2. Data flows one direction — props down, events up.
  3. State lives as close as possible to where it’s actually used.

Get those three and the rest of React clicks into place.

On this page

A component is just a function

React’s documentation describes a component as “a JavaScript function that you can sprinkle with markup.” That’s the most useful way to hold it in your head.

export default function Greeting({ name }) {
  return <h1>Good morning, {name}</h1>;
}

Three rules apply. First, the function name starts with a capital letter — React uses this to tell your component apart from a plain HTML tag. Write <greeting /> and React treats it as an unknown HTML element; write <Greeting /> and it calls your function. Second, the function returns JSX. Third, the return value is one root element — you can’t return two siblings at the same level. Wrap multiple elements in <>...</> (a Fragment) when you need to return several without adding an extra div to the DOM.

Nesting components is as natural as calling functions:

function Card({ title, children }) {
  return (
    <div className="card">
      <h2>{title}</h2>
      {children}
    </div>
  );
}

export default function Dashboard() {
  return (
    <Card title="Activity">
      <p>Three items pending.</p>
    </Card>
  );
}

The className attribute instead of class is the first “wait, what?” moment for people coming from HTML. JSX compiles to JavaScript, and class is a reserved keyword, so React uses className throughout.

JSX: markup inside JavaScript

JSX looks like HTML inside a .jsx file, but it’s JavaScript in disguise. Any JavaScript expression can live inside {}. String concatenation, ternary operators, function calls — if it’s an expression, you can drop it between the curly braces.

function Status({ isOnline }) {
  return (
    <span className={isOnline ? "badge-green" : "badge-grey"}>
      {isOnline ? "Online" : "Offline"}
    </span>
  );
}

You can’t put statements (if, for, switch) directly inside {}. Keep logic before the return statement, assign the result to a variable, then interpolate the variable. The JavaScript array methods post covers how .map() and .filter() work in detail — they’re expressions, which makes them the natural tools for building lists in JSX.

Props: inputs a component cannot change

Props are the way a parent passes information to a child. Think of them as function arguments — you read them, you use them, you do not overwrite them.

React’s documentation is direct about immutability: props are read-only snapshots. When a component needs to change something based on user interaction, it doesn’t touch its own props — it calls an event handler that was passed down from a parent, and the parent decides how to respond.

Destructuring in the function signature is the standard pattern:

function UserCard({ name, role, avatarUrl }) {
  return (
    <div className="user-card">
      <img src={avatarUrl} alt={name} />
      <p>{name}{role}</p>
    </div>
  );
}

You can set defaults when a prop is optional:

function Badge({ label, colour = "blue" }) {
  return <span className={`badge badge-${colour}`}>{label}</span>;
}

This is the one-way data flow rule in practice. The parent owns the data; the child reads it. When the child needs to trigger a change — say, a button click — it calls an event handler that was passed down as a prop. That handler is a closure over the parent’s state variables. Understanding closures in JavaScript makes this pattern click: the function the parent passes carries its own scope with it, so the child can call it without needing to know anything about the parent’s internals.

Data flows down. Events bubble up. This predictability is what makes React apps easy to trace through once they grow.

State: what React remembers between renders

Here’s the thing that confuses everyone early on. You write:

let count = 0;

function handleClick() {
  count = count + 1;
}

You click the button. Nothing on screen changes. Why?

React explains this clearly: a local variable inside a component doesn’t persist between renders — each render calls the function fresh, starting from zero. And even if the variable did update, React has no way to know about the change, so it has no reason to re-render.

useState solves both problems at once:

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  );
}

useState(0) returns two things: the current value (count) and the setter function (setCount). When you call setCount, React stores the new value and schedules a re-render. On the next render, useState returns the updated value instead of the initial 0.

The [count, setCount] syntax is array destructuring — the convention is [value, setValue], but the names are yours to choose. Hooks — functions whose names start with use — have one strict rule: call them at the top level of your component, never inside an if, a loop, or a nested function. React matches each state variable to its useState call by the order those calls appear in the function. Shift a hook into a conditional and the order changes across renders, which breaks everything.

Before you continue: What do you think happens when you call setCount twice in the same click handler — does count update twice, or once?

The answer: once per render. Both calls see the same count value from the current render snapshot and schedule one re-render with the final result. Functional updates — setCount(prev => prev + 1) — solve this when you genuinely need sequential increments.

Controlled inputs and list rendering

State and props meet most visibly in form inputs. A controlled input reads from state and reports every keystroke back through an event handler:

function SearchBox() {
  const [query, setQuery] = useState('');

  return (
    <input
      type="text"
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}

The input’s displayed value is always query. This feels circular at first, but the benefit is clear: query is a reliable source of truth at any moment — ready to validate, send to an API, or pass to another component.

For lists, .map() turns an array of data into an array of JSX elements. Each element needs a stable key prop:

function TaskList({ tasks }) {
  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.title}</li>
      ))}
    </ul>
  );
}

The key prop is how React tells elements apart across renders. Imagine tasks can be reordered or filtered. Without a key, React falls back to index position, and adding a task at the top scrambles the state of every item below it. React itself explains this well: keys work like file names on a desktop — without them, you’d only have “first file” and “second file,” and deleting one shifts everything. Use data-derived IDs, not array indexes.

Conditional rendering is plain JavaScript: a ternary inside JSX, the && short-circuit, or an if before the return:

{isLoggedIn && <UserMenu />}
{errorMessage ? <ErrorBanner message={errorMessage} /> : null}

One common bug worth naming: {count && <Badge />} renders the number 0 when count is zero. JavaScript evaluates 0 && anything as 0, and React renders that. Fix it with an explicit boolean check: {count > 0 && <Badge />}.

The state mutation mistake

The most common early React bug is mutating state directly:

// Wrong — passes the same array reference
function addItem(item) {
  items.push(item);
  setItems(items);
}

// Right — creates a new array reference
function addItem(item) {
  setItems([...items, item]);
}

When you pass the same array reference to the setter, React’s comparison sees “nothing changed” and skips the re-render. Always produce a new value: spread arrays, use .map() to update objects inside arrays, use object spread for plain objects. The mutation bug is subtle because the data is changing — the screen just never shows it.

If you’re working with TypeScript, typing your props and state catches the wrong-shape mutations before they reach the browser. The TypeScript for JavaScript developers guide covers the type syntax that carries over directly into React components.

Keep state close to where it’s used

There’s a natural temptation to lift all state to a top-level component so every child can access it. That leads to unnecessary re-renders and props getting passed through many layers to reach the one child that needs them.

The principle React applies is called state colocation: state belongs as low in the component tree as it can live while still being accessible to everything that needs it. A dropdown that only affects its own appearance? State lives in that dropdown component. Two sibling components that share the same value? Lift the state to their nearest common parent and pass it down as props.

This is the practical version of principle three. The component that owns the state is the component whose function runs when that state changes. Keep that function close to the UI it controls, and re-renders stay fast and easy to follow.

UI is a function — the rest follows

The whole React model reduces to one equation: a component is a function that maps its current inputs to UI. Change the inputs, and the function runs again with the new values. Props are the inputs the parent controls; state is the input the component controls itself. JSX is the output.

Once that equation is clear, every React pattern you’ll encounter — lifted state, context, server components — is a variation on how you manage what goes into that function.

Hooks go much deeper than useState. The next post covers useEffect for synchronising with things outside React, useRef for values that persist between renders without triggering re-renders, and useCallback for stabilising function references across renders. Each one follows the same pattern: a hook is a declared need the component has, resolved by React on every render.

Sources