Step 12 of 21

React fundamentals

Build UIs from typed React components: write JSX, pass props and children, render conditionally and render lists with keys.

16 min 8 sections

React lets you describe your UI as a tree of components: small functions that take data and return what the screen should look like. Instead of writing step-by-step DOM updates, you say "given this data, the page looks like this", and React works out the DOM changes. This lesson covers components, JSX, props, conditional rendering and lists. State and effects, the parts that make components interactive over time, come in the next lesson.

Components are functions

A React component is a JavaScript function that returns markup. Its name must start with a capital letter; that is how JSX tells your components apart from built-in tags like <div>.

tsx
// src/Greeting.tsx
export function Greeting() {
  return <h1>Hello, LevelCode!</h1>
}

Components are composed by using them inside each other, exactly like HTML tags:

tsx
// src/App.tsx
import { Greeting } from './Greeting'

export function App() {
  return (
    <main>
      <Greeting />
      <p>Welcome back.</p>
    </main>
  )
}

Something has to put the root component on the page. In a Vite project that happens once, in src/main.tsx:

tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

createRoot takes over the <div id="root"> in index.html. StrictMode adds extra development-only checks, which you will meet again below. Everything else in your app is components rendering components.

JSX: markup inside JavaScript

JSX looks like HTML, but it compiles to JavaScript function calls. <h1 className="title">Hi</h1> becomes, roughly, jsx('h1', { className: 'title', children: 'Hi' }), which produces a plain object describing the element. Because it is JavaScript, a few rules differ from HTML:

  • Return a single root. Wrap siblings in an element or in a fragment, <>...</>, which adds nothing to the DOM.
  • Close every tag, including void ones: <img />, <input />, <br />.
  • Attributes are camelCase DOM properties: className instead of class, htmlFor instead of for, onClick, tabIndex. The exceptions are aria-* and data-*, which keep their dashes.
  • style takes an object: style={{ marginTop: 8 }}. A bare number means pixels.

Curly braces switch from markup back into JavaScript. Inside them you can put any expression: variables, function calls, arithmetic, ternaries. You cannot put statements such as if or for.

tsx
const user = { name: 'Ada', avatarUrl: '/ada.png' }

export function Profile() {
  return (
    <>
      <img src={user.avatarUrl} alt="" className="avatar" />
      <h2>{user.name.toUpperCase()}</h2>
      <p>Member for {new Date().getFullYear() - 2019} years</p>
    </>
  )
}

React escapes everything you put in braces, so a string containing <script> is displayed as text instead of being run. That protects you from most cross-site scripting bugs by default.

Props: passing data down

Components receive data through props, a single object argument. In TypeScript you describe it with a type and destructure it in the parameter list, giving defaults where it makes sense:

tsx
type BadgeProps = {
  label: string
  tone?: 'neutral' | 'success' | 'danger'
}

export function Badge({ label, tone = 'neutral' }: BadgeProps) {
  return <span className={`badge badge-${tone}`}>{label}</span>
}

// Usage: <Badge label="New" tone="success" /> and <Badge label="Draft" />

Strings can be passed in quotes; everything else (numbers, booleans, objects, functions) goes in braces: count={3}, disabled={true} or just disabled.

Props are read-only. A component must never modify its props object. Data flows one way, from parent to child: if something needs to change, the parent renders the child again with new props.

Children

Whatever you nest between a component's tags arrives as the special children prop. This is how you build wrappers like cards, layouts and dialogs:

tsx
import type { ReactNode } from 'react'

type CardProps = { title: string; children: ReactNode }

export function Card({ title, children }: CardProps) {
  return (
    <section className="card">
      <h2>{title}</h2>
      {children}
    </section>
  )
}

<Card title="Billing">
  <p>Your next invoice is due on 1 March.</p>
</Card>

ReactNode is the type for "anything React can render": elements, strings, numbers, arrays of those, null, undefined and booleans.

Passing functions as props

A parent tells a child what to do on an event by passing a function. Pass the function itself; do not call it:

tsx
type DeleteButtonProps = { onDelete: () => void }

function DeleteButton({ onDelete }: DeleteButtonProps) {
  return <button type="button" onClick={onDelete}>Delete</button>
}

function Row({ id, remove }: { id: string; remove: (id: string) => void }) {
  return <DeleteButton onDelete={() => remove(id)} />
}

onClick={remove(id)} would call remove during rendering, deleting the item as soon as it appears. The arrow function () => remove(id) creates a function that runs later, when the click happens.

Conditional rendering

Since JSX is just values, you choose what to render with ordinary JavaScript. Use an early return for whole alternatives, a ternary for either/or inside markup, and && for "this or nothing":

tsx
type InboxProps = { unread: number; loggedIn: boolean }

export function Inbox({ unread, loggedIn }: InboxProps) {
  if (!loggedIn) {
    return <p>Please sign in.</p>
  }

  return (
    <div>
      <h2>{unread > 0 ? `Inbox (${unread})` : 'Inbox'}</h2>
      {unread > 0 && <p>You have new messages.</p>}
    </div>
  )
}

Returning null renders nothing. false, true, null and undefined inside JSX also render nothing, but numbers do render, including 0.

Rendering lists

To render a collection, transform the data array into an array of elements with map. You can filter first, since it is all plain JavaScript:

tsx
type Task = { id: string; title: string; done: boolean }

export function TaskList({ tasks }: { tasks: Task[] }) {
  const open = tasks.filter((task) => !task.done)

  if (open.length === 0) return <p>All done!</p>

  return (
    <ul>
      {open.map((task) => (
        <li key={task.id}>{task.title}</li>
      ))}
    </ul>
  )
}

Keys

Every element in a list needs a key that is unique among its siblings. React uses keys to match items between renders: when the list changes, it knows which item moved, which was inserted and which was removed, and it keeps each item's DOM and component state attached to the right one.

Key choiceVerdict
task.id from your dataCorrect: stable and unique
Array index iOnly acceptable for static lists that never reorder, insert or delete
Math.random() or crypto.randomUUID() during renderWrong: a new key every render recreates every item and loses its state

With index keys, deleting the first item shifts every index. React then thinks the last item was removed and that every other item changed, so state such as a half-typed input in a row ends up attached to the wrong task. Keys only need to be unique within one list, and key is consumed by React: the component does not receive it as a prop, so pass the id separately if the child needs it.

Rendering must be pure

React may call your component function whenever it needs to work out what the UI looks like, and it may call it more than once. So a component must behave like a pure function of its props: same inputs, same JSX, no side effects while rendering.

tsx
let renderCount = 0

function Impure() {
  renderCount++                       // mutates something outside: wrong
  return <p>Rendered {renderCount} times</p>
}

Do not mutate variables or objects that existed before the render, and do not change the DOM, start requests or set timers in the function body. Side effects belong in event handlers, or in effects, which the next lesson covers. Creating and modifying new local variables inside the render is fine.

In development, StrictMode calls each component function twice to surface impurities like the one above: if the output differs between the calls, you have a bug. It does not do this in production.

Thinking in components

When you get a design, draw boxes around its parts. Each box that has a single job, or that repeats, is a candidate component. A product page might be ProductPage containing ProductGallery, ProductInfo and a ReviewList of ReviewItems. Decide what data each needs, pass it down as props, and keep each component small enough to understand at a glance.

Practice

In a Vite react-ts project, build a product catalogue from this data:

ts
export type Product = { id: string; name: string; priceCents: number; stock: number }

export const products: Product[] = [
  { id: 'p1', name: 'Desk lamp', priceCents: 3900, stock: 4 },
  { id: 'p2', name: 'Notebook', priceCents: 650, stock: 0 },
  { id: 'p3', name: 'Fountain pen', priceCents: 2400, stock: 12 },
]
  1. A ProductCard that shows the name and a formatted price, plus a "Sold out" badge when stock is 0.
  2. A ProductList that takes products and an optional inStockOnly boolean prop and renders a card per product with a correct key.
  3. When nothing matches, show "No products found." instead of an empty list.
  4. Render <ProductList products={products} inStockOnly /> from App.
Show solution
tsx
// src/ProductList.tsx
import type { Product } from './products'

const price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })

function ProductCard({ product }: { product: Product }) {
  const soldOut = product.stock === 0
  return (
    <article className="card">
      <h3>{product.name}</h3>
      <p>{price.format(product.priceCents / 100)}</p>
      {soldOut && <span className="badge">Sold out</span>}
    </article>
  )
}

type ProductListProps = { products: Product[]; inStockOnly?: boolean }

export function ProductList({ products, inStockOnly = false }: ProductListProps) {
  const visible = inStockOnly ? products.filter((p) => p.stock > 0) : products

  if (visible.length === 0) {
    return <p>No products found.</p>
  }

  return (
    <div className="grid">
      {visible.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}
tsx
// src/App.tsx
import { ProductList } from './ProductList'
import { products } from './products'

export function App() {
  return (
    <main>
      <h1>Catalogue</h1>
      <ProductList products={products} inStockOnly />
    </main>
  )
}

With inStockOnly, the page shows the lamp ($39.00) and the pen ($24.00). Notice that the filtering happens during rendering, that soldOut is a real boolean so && is safe, and that the key goes on ProductCard, the element returned directly from map.

Key takeaways

  • A component is a capitalised function that returns JSX; you build UIs by composing components.
  • JSX compiles to JavaScript: one root, closed tags, camelCase attributes and expressions in braces.
  • Props flow down and are read-only; children lets components wrap other content, and functions passed as props let children report events.
  • Use early returns, ternaries and && for conditions, and make sure the left side of && is a boolean.
  • Render lists with map and give each item a stable, unique key, usually the id from your data.
  • Rendering must be pure; StrictMode double-renders in development to expose violations.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.`items` is an empty array. What does `<div>{items.length && <ItemList items={items} />}</div>` render?

  2. 2.Which is the best key for rows in a list of orders loaded from your API?

  3. 3.A button is written as `<button onClick={handleDelete(order.id)}>`. What happens?

  4. 4.A child component needs to display a different label after something changes. What is the correct way to do it?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.