Mert Özen Further With Every Line
Frontend React — #03

JSX and Components: JavaScript Instead of Templates, Props, children and Rendering Lists

Mert Özen Nov 15, 2026 12 min 4 views
JSX and Components: JavaScript Instead of Templates, Props, children and Rendering Lists

JSX is not a template language but JavaScript compiled to _jsx() calls at build time; conditions use &&, the ternary and early returns, lists use map with a key taken from the data's own identity. In Bookstore's BookCard, BookList and Layout components we type props with TypeScript, build the counterpart of ng-content with children, and show with real runs that key={index} moves a note to the wrong book, that 0 && prints 0 and that dangerouslySetInnerHTML runs onerror code.

Version note: The examples in this post were built and run with React 19.3, TypeScript 7.0, Vite 8.3, Oxlint 1.93 and Node.js 24; browser behaviour was observed with Playwright 1.65 in headless Chromium on the production build.

Predict First

The React client of Bookstore, the online bookstore used as the shared example across this site's series, has a list of three books: Dune, The Hobbit, Emma. Each row shows the book's title and an <input> where the user can write a note. Above the list, a "Reverse" button flips the order. The rows are produced with books.map(...) and every row gets a key.

      <ul>
        {books.map((book, index) => (
          <li key={useIndexKey ? index : book.isbn}>
            {book.title} <input aria-label={`Note for ${book.title}`} placeholder="Note" />
          </li>
        ))}
      </ul>

The user types "gift for Ada" into the Dune row and presses "Reverse". The list becomes Emma, The Hobbit, Dune. The question: next to which book does the note appear? Is there a difference between key={book.isbn} and key={index}? What would happen in Angular with @for (book of books; track $index)? The answer is in the real output of both runs in the list section.

In this post we cover JSX, which React uses instead of templates, props, composition with children, conditional rendering and list rendering. The examples are in the series app: BookCard, BookList and Layout.

What Does JSX Compile To?

JSX never reaches the browser; during the build it is turned into plain JavaScript function calls. We ran a small component through the Oxc transform that Vite uses. Input:

export function BookTitle({ title, year }: { title: string; year: number }) {
  return (
    <h2 className="title">
      {title} {year < 1900 && <span>Classic</span>}
    </h2>
  );
}

Output:

import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export function BookTitle({ title, year }) {
	return /* @__PURE__ */ _jsxs("h2", {
		className: "title",
		children: [
			title,
			" ",
			year < 1900 && /* @__PURE__ */ _jsx("span", { children: "Classic" })
		]
	});
}

Every JSX element becomes a _jsx or _jsxs call: the first argument is the tag name, the second the props object. Children go into a prop called children. The TypeScript types are gone too. These calls do not create DOM; they return ordinary objects that describe what should be on the screen right now. React DOM updates the DOM according to that description.

The transform also explains JSX's rules. A component can return one value; to return two sibling elements side by side you wrap them in an element or a Fragment (<>...</>). A Fragment leaves no trace in the DOM; think of it as the counterpart of Angular's ng-container. Since attributes are keys of the props object, JavaScript names apply: className instead of class, htmlFor instead of for, strokeWidth instead of stroke-width. Any JavaScript expression can go inside the braces { }; statements such as if or for cannot, because the result ends up as a function argument.

An Angular template, by contrast, is compiled from its own language: @if, @for, pipes and [property] bindings are constructs the Angular compiler understands. In React the counterpart of all of these is JavaScript itself: &&, the ternary, map and function calls.

Props and TypeScript

At the end of this post the series' card looks like this:

import type { BookSummary } from '@/features/catalog/types';

type BookCardProps = {
  book: BookSummary;
  quantity: number;
  onAdd: (isbn: string) => void;
};

const price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'TRY', currencyDisplay: 'narrowSymbol' });

export function BookCard({ book, quantity, onAdd }: BookCardProps) {
  const label = quantity === 0 ? 'Add to cart' : `In cart: ${quantity}`;

  return (
    <li className="book-card">
      <strong>{book.title}</strong>
      <span className="muted">
        {book.author} · {book.publishedYear}
        {book.publishedYear < 1900 && <span className="badge">Classic</span>}
      </span>
      <strong>{price.format(book.price)}</strong>
      <button type="button" onClick={() => onAdd(book.isbn)}>{label}</button>
    </li>
  );
}

The props type is a plain TypeScript type. The counterpart of Angular's input.required<BookSummary>() is a field without ? in the type; try to use the card without book and tsc -b stops the build. For an optional prop you put ? after the field name and give the default value in the destructuring: { quantity = 0 }.

react.dev describes props as "immutable", a new snapshot for every render: a component does not change its own props. The card wants to know the quantity in the cart but does not hold it; when it wants to change it, it tells the parent through onAdd, and the parent renders it again with a new quantity. Just as you cannot write to an input() signal from inside an Angular component, in React you do not write to props. This rule is part of the "keep components pure" principle: a component called with the same props should return the same JSX.

A note: the price formatter lives outside the component, at module level. Every line in a component body runs on every render, so an object that never changes and is costly to create belongs outside.

Composition with children: The Counterpart of ng-content

In Angular a layout component places the content put inside it with <ng-content />; for more than one place you use select. In React the content arrives as a special prop called children, and extra "slots" are ordinary props:

import type { ReactNode } from 'react';

type LayoutProps = {
  title: string;
  // Like a named ng-content slot: the caller decides what goes into the header
  headerExtra?: ReactNode;
  children: ReactNode;
};

export function Layout({ title, headerExtra, children }: LayoutProps) {
  return (
    <>
      <header className="site-header">
        <strong>{title}</strong>
        {headerExtra}
      </header>
      <main>{children}</main>
    </>
  );
}

ReactNode is the type of everything that can go inside JSX: an element, a string, a number, null, or an array of these. headerExtra works like a named slot, the counterpart of <ng-content select="[header-extra]" /> in Angular. The App that uses the layout:

import { useState } from 'react';
import { CartSummary } from '@/features/cart/CartSummary';
import { BookList } from '@/features/catalog/BookList';
import { sampleBooks } from '@/features/catalog/sample-books';
import { Layout } from '@/shared/Layout';

export default function App() {
  // isbn -> quantity; the next post explains why we always build a new object
  const [quantities, setQuantities] = useState<Record<string, number>>({});
  const itemCount = Object.values(quantities).reduce((sum, q) => sum + q, 0);

  function handleAdd(isbn: string) {
    setQuantities((current) => ({ ...current, [isbn]: (current[isbn] ?? 0) + 1 }));
  }

  return (
    <Layout title="Bookstore" headerExtra={<CartSummary itemCount={itemCount} />}>
      <h1>Catalog</h1>
      <BookList books={sampleBooks} quantities={quantities} onAdd={handleAdd} />
    </Layout>
  );
}

Everything between the opening and closing tags becomes children; headerExtra receives a JSX element as its value. In React JSX is a value, so it can be assigned to a variable, passed as a prop and returned from a function. Where Angular needs TemplateRef and ngTemplateOutlet to hand a piece of template to someone else, in React it is the natural thing to do.

In React, composition takes the place of inheritance. Instead of deriving from a "BaseCard" class, you put small components inside each other. The split between container and presentational components was covered for Angular in "Component Architecture: Container and Presentational Components, Composition and Contracts" in the UI Architecture Patterns series; we use the same ideas in React in the fifth post, where we discuss where state should live.

Conditional Rendering: && and the Ternary Instead of @if

We added a "Classic" badge to the card for books published before 1900: {book.publishedYear < 1900 && <span className="badge">Classic</span>}. In JavaScript a && b returns a if a is falsy, otherwise b. React skips false, null, undefined and true without drawing anything, so when the condition is false no badge appears. When you need two branches you use a ternary; the card's button label works that way.

You do not have to stay inside JSX for a long chain of conditions. BookList handles the empty list with an early return at the top of the function, the counterpart of the @for ... @empty block in Angular:

import { BookCard } from '@/features/catalog/BookCard';
import type { BookSummary } from '@/features/catalog/types';

type BookListProps = {
  books: BookSummary[];
  quantities: Record<string, number>;
  onAdd: (isbn: string) => void;
};

export function BookList({ books, quantities, onAdd }: BookListProps) {
  if (books.length === 0) {
    return <p className="muted">No books match your search.</p>;
  }

  return (
    <ul className="book-grid">
      {books.map((book) => (
        // key: a stable identity like @for's track; never the array index
        <BookCard key={book.isbn} book={book} quantity={quantities[book.isbn] ?? 0} onAdd={onAdd} />
      ))}
    </ul>
  );
}

&& has a trap: if the left side is 0, React writes 0 to the screen as a number, because 0 is falsy but it is not false. We tried it with the number of items in the cart:

export default function ZeroDemo() {
  const itemCount = 0;
  return (
    <main>
      <p id="wrong">Cart {itemCount && <span>({itemCount} items)</span>}</p>
      <p id="right">Cart {itemCount > 0 && <span>({itemCount} items)</span>}</p>
    </main>
  );
}
#wrong: "Cart 0"
#right: "Cart"

With an empty cart the first paragraph shows "Cart 0". Making the left side a real boolean every time (itemCount > 0) removes this bug.

List Rendering and key: The Counterpart of @for track

books.map(...) inside BookList produces a BookCard element for every book. key is the identity React uses to match an element in one render with the element in the next. key is what the track expression of @for, introduced in Angular 17, is; in Angular track is required, while in React a missing key produces a console warning in development, and Oxlint's jsx-key rule catches it before the build.

The development console for a list that renders three titles without keys:

error: Each child in a list should have a unique "key" prop.%s%s See https://react.dev/link/warning-keys for more information. 

The first state of the page in the production build and the header after adding two books:

Dune | Frank Herbert · 1965 | ₺489.00 | Add to cart
The Hobbit | J.R.R. Tolkien · 1937 | ₺329.90 | Add to cart
Emma | Jane Austen · 1815Classic | ₺249.90 | Add to cart
Middlemarch | George Eliot · 1871Classic | ₺389.00 | Add to cart
1984 | George Orwell · 1949 | ₺279.00 | Add to cart
header: Bookstore | Cart: 3 item(s)
Emma button: In cart: 2

Now the experiment from "Predict First". The value of the input in each row after typing "gift for Ada" into the Dune row and pressing "Reverse":

key={book.isbn}  Emma=""  The Hobbit=""  Dune="gift for Ada"
key={index}      Emma="gift for Ada"  The Hobbit=""  Dune=""

With key={book.isbn} the note stayed next to Dune. With key={index} the note moved next to Emma, which went to the top of the list. The reason: React matches by key; before the reverse, index 0 was Dune, afterwards Emma. Since the key was the same (0), React read it as "same row, only its text changed": it updated the text but left the input, and its value in the DOM, as it was. If the row had its own state (like the quantity picker we add to the cards in the next post), that would have moved to the wrong book too. In Angular, track $index produces the same bug.

The rule is simple: the key should be the data's own stable identity. In Bookstore that is the ISBN for a book and the order number for an order. An index is only safe if the list is never sorted, filtered or inserted into; it is hard to be sure those conditions will not break later. Generating a key during render with Math.random() or crypto.randomUUID() is worse: every row is recreated on every render. The no-array-index-key rule we added to the project flags index keys:

src/App.tsx:7:13: warning react(no-array-index-key): Usage of Array index in keys is not allowed
    help: Use a unique data-dependent key to avoid unnecessary rerenders

XSS: What JSX Escapes and What dangerouslySetInnerHTML Opens

JSX writes strings placed inside { } to the DOM as text, not as HTML, and that is an important security property. We showed a book review written by a user in two ways:

// A review text that came from a user; imagine it arrived from the API
const review = 'Great book <img src="x" onerror="console.log(\'XSS ran on \' + location.host)">';

export default function XssDemo() {
  return (
    <main>
      <p id="escaped">{review}</p>
      <p id="raw" dangerouslySetInnerHTML={{ __html: review }} />
    </main>
  );
}
console: XSS ran on 127.0.0.1:52143
#escaped innerHTML: Great book &lt;img src="x" onerror="console.log('XSS ran on ' + location.host)"&gt;
#raw innerHTML:     Great book <img src="x" onerror="console.log('XSS ran on ' + location.host)">

In the first paragraph the review stayed text: &lt;img was written to the screen character by character and nothing ran. In the second, dangerouslySetInnerHTML passed the string to innerHTML, the browser created the <img>, onerror ran when the image failed to load and the page address was written to the console. In a real attack that line would be code that reads cookies or sends requests on the user's behalf. The "dangerously" in the prop's name says exactly this: like bypassSecurityTrustHtml in Angular, React's protection is switched off at this point.

We turned on Oxlint's no-danger rule as a warning, so every use becomes visible in code review:

src/App.tsx:8:19: warning react(no-danger): Do not use `dangerouslySetInnerHTML` prop
    help: `dangerouslySetInnerHTML` is a way to inject HTML into your React component. This is dangerous because it can easily lead to XSS vulnerabilities.
exit code: 0

If you really need to show HTML (such as a book blurb coming from a CMS), the content should go through a sanitizer like DOMPurify, preferably on the server. React 19.3 also supports the browser's Trusted Types API: React now passes TrustedHTML objects to the DOM without turning them into strings, so policies really work under a CSP containing require-trusted-types-for 'script'. The details are in the Frontend Security series: "Taking Trusted Types to Production: Angular Policies, Third-Party Libraries and Baseline 2026". JSX escaping alone is not enough; there are other sinks, such as putting a user-supplied URL into href, and they are covered in the same series.

In Production

Take list keys from the identity the API returns. The Bookstore API returns the ISBN for books and for cart lines too; there is no need to keep a separate counter for keys. For data without an identity (rows a user added in a form but has not saved yet) generate the identity once when the row is created (crypto.randomUUID()) and keep it in state; never generate it during render.

In large lists (thousands of books) putting every item into the DOM gets slow; the fix is virtualization, a topic of the performance post. Getting keys right is the first requirement there too: when React can move DOM nodes in a reordered list instead of recreating them, it does so because of keys.

Keep dangerouslySetInnerHTML uses visible with lint, and next to each one write where the content comes from and where it is sanitized. Check this when choosing a component library too: some rich text and markdown components use this prop internally.

Common Pitfalls

Putting the key in the wrong place: key goes on the outermost element returned by map. Putting it on the <li> inside the card does nothing, because React compares the siblings at the level of <BookCard> in BookList. The key looks like a prop but never reaches the component; props.key cannot be read inside BookCard.

Leaving a number or a string on the left of &&: the "Cart 0" bug above. Write items.length > 0 && ... instead of items.length && ....

Changing props or an outside array during render: books.sort(...) sorts the parent's array in place and gives unexpected results on the next render. Use toSorted for a sorted copy; we see it in the fourth post.

Defining a component inside another component: if you write function Row() {...} inside the App function, a new component type is created on every render and React resets the state of that subtree every time. Components are defined at module level.

A Small Experiment

★☆☆ Add a line to BookCard that shows category; if the category is "Science Fiction", show a small SF badge next to it. Write it once with && and once with a ternary.

★★☆ Add an optional slot called footer to Layout. When it is not given, it should show "Bookstore · 2026" by default. How would you do the same with ng-content in Angular?

★★★ Add a "Shuffle" button to the list from "Predict First" and shuffle a few times with key={index}. Which rows keep the note? Then consider binding the input to state instead of leaving it uncontrolled: does that alone fix the bug in a list keyed by index?

The card now shows a number, but the only thing that changes the number is one line in App. In the next post we look at events, useState, state being a snapshot per render, the updater function and when React re-renders a component, with real render logs.