Skip to main content
React · lesson 1 of 22
React beginner Lesson 1 of 22

Also part of: Next.js

Your First React Component

Write a React component, render it to HTML with renderToString, and see JSX's real text-node separators and the falsy-zero rendering gotcha.

By SkillByExample · Published

react jsx components

A React component is a function that returns JSX. Rendering it with renderToString from react-dom/server prints the real HTML, with no browser involved, which is how every example in this topic gets its output.

Everything here lives in one small project: a tsconfig.json with "jsx": "react-jsx" (the automatic runtime, so components never need import React) and a package.json with "type": "module". Every script runs with npx tsx, which strips JSX and types without type-checking them. Versions: React 19.3.1, Node 24.

// tsconfig.json
{
  "compilerOptions": {
    "module": "nodenext",
    "jsx": "react-jsx",
    "noEmit": true
  },
  "include": ["src"]
}

A Function That Returns JSX

A component is a regular function, capitalized, returning one JSX element.

// src/first-component/Greeting.tsx
type GreetingProps = { name: string };

export function Greeting({ name }: GreetingProps) {
  return <p>Hello, {name}</p>;
}
// src/first-component/render-greeting.tsx
import { renderToString } from "react-dom/server";
import { Greeting } from "./Greeting.js";

console.log(renderToString(<Greeting name="Ada" />));
console.log(renderToString(<Greeting name="Grace" />));
$ npx tsx src/first-component/render-greeting.tsx
<p>Hello, <!-- -->Ada</p>
<p>Hello, <!-- -->Grace</p>

<p>Hello, Ada</p> is what you’d predict. The <!-- --> is real: it’s React’s separator between two adjacent text nodes — the static string "Hello, " and the dynamic value {name}. It’s invisible in a browser but it is genuinely in the markup, and it only appears between two text nodes next to each other, not around a single one.

Falsy Values Render as Text

{expression} renders whatever the expression evaluates to, which is a common source of a specific, predictable surprise.

// src/first-component/Cart.tsx
type CartProps = { itemCount: number };

export function Cart({ itemCount }: CartProps) {
  return <div>{itemCount && <p>{itemCount} items in cart</p>}</div>;
}
// src/first-component/render-cart.tsx
import { renderToString } from "react-dom/server";
import { Cart } from "./Cart.js";

console.log(renderToString(<Cart itemCount={3} />));
console.log(renderToString(<Cart itemCount={0} />));
$ npx tsx src/first-component/render-cart.tsx
<div><p>3<!-- --> items in cart</p></div>
<div>0</div>

With itemCount={3}, 3 && <p>...</p> evaluates to the <p> element, as expected. With itemCount={0}, 0 && <p>...</p> evaluates to 0 — the left side of &&, because it’s falsy — and React renders that number as the text 0, sitting alone in the <div>. null, undefined, and booleans render as nothing; 0 and "" are falsy too, but they’re valid, visible content, so React prints them.

The fix is a comparison that actually produces a boolean:

// src/first-component/CartFixed.tsx
type CartProps = { itemCount: number };

export function CartFixed({ itemCount }: CartProps) {
  return <div>{itemCount > 0 && <p>{itemCount} items in cart</p>}</div>;
}
// src/first-component/render-cart-fixed.tsx
import { renderToString } from "react-dom/server";
import { CartFixed } from "./CartFixed.js";

console.log(renderToString(<CartFixed itemCount={3} />));
console.log(renderToString(<CartFixed itemCount={0} />));
$ npx tsx src/first-component/render-cart-fixed.tsx
<div><p>3<!-- --> items in cart</p></div>
<div></div>

itemCount > 0 is false for 0, and false renders as nothing — the empty <div></div>.

Practice

1. Add an optional age prop to Greeting and render (age) after the name when it’s given.

Solution
import { renderToString } from "react-dom/server";

type GreetingProps = { name: string; age?: number };

function Greeting({ name, age }: GreetingProps) {
  return (
    <p>
      Hello, {name}
      {age !== undefined && ` (${age})`}
    </p>
  );
}

console.log(renderToString(<Greeting name="Ada" age={36} />));
console.log(renderToString(<Greeting name="Grace" />));
$ npx tsx practice1.tsx
<p>Hello, <!-- -->Ada<!-- --> (36)</p>
<p>Hello, <!-- -->Grace</p>

The second <!-- --> sits between {name} and the new {age !== undefined && ...} expression — two more adjacent dynamic nodes.

2. Render Greeting with name="" and predict the output before running it.

Solution
import { renderToString } from "react-dom/server";
import { Greeting } from "./Greeting.js";

console.log(renderToString(<Greeting name="" />));
$ npx tsx practice2.tsx
<p>Hello, </p>

No <!-- --> this time: {name} is the only dynamic child and there’s nothing adjacent to it, so there’s no boundary for React to mark. The separator only shows up between two text nodes next to each other, as in the first example.

3. Change Cart to show “Cart is empty” instead of a blank <div> when itemCount is 0.

Solution
import { renderToString } from "react-dom/server";

type CartProps = { itemCount: number };

function CartWithEmptyMessage({ itemCount }: CartProps) {
  return (
    <div>
      {itemCount > 0 ? <p>{itemCount} items in cart</p> : <p>Cart is empty</p>}
    </div>
  );
}

console.log(renderToString(<CartWithEmptyMessage itemCount={3} />));
console.log(renderToString(<CartWithEmptyMessage itemCount={0} />));
$ npx tsx practice3.tsx
<div><p>3<!-- --> items in cart</p></div>
<div><p>Cart is empty</p></div>

A ternary always returns one branch or the other, so there’s no falsy-left-operand trap the way there is with &&.

4. Render Greeting with name set to "<script>alert(1)</script>" and check what ends up in the markup.

Solution
import { renderToString } from "react-dom/server";
import { Greeting } from "./Greeting.js";

console.log(renderToString(<Greeting name={"<script>alert(1)</script>"} />));
$ npx tsx practice4.tsx
<p>Hello, <!-- -->&lt;script&gt;alert(1)&lt;/script&gt;</p>

The angle brackets come out as &lt; and &gt;, not as a real <script> tag. JSX escapes every value placed in {} before it becomes HTML.

Frequently Asked Questions

Do I need to import React to write JSX?
No. With the automatic JSX runtime ("jsx": "react-jsx" in tsconfig, the default in modern tooling), the compiler inserts its own imports from react/jsx-runtime behind the scenes. You only need to import hooks and APIs you call directly, such as useState or renderToString.
What does <!-- --> mean in React's rendered HTML?
It is a real comment node React inserts between two adjacent text nodes, such as a string followed by a number. It has no visual effect in a browser, but it marks where one text node ends and the next begins, which matters when React or the browser later needs to update just one of them.
Why does itemCount && <Badge /> print 0 instead of nothing when itemCount is 0?
The && operator returns its left operand when that operand is falsy, and React renders numbers as text. 0 is falsy but not null, undefined, true, or false, so React renders the literal character 0. Use a comparison that produces a boolean, such as itemCount > 0 && <Badge />, to avoid it.
Is renderToString the same as what happens in a browser?
renderToString produces the same HTML markup a browser would end up with, but it runs once on the server (or here, in a script) with no DOM and no interactivity. A browser additionally attaches event listeners during hydration; a plain renderToString call never does that.
Does JSX escape values automatically?
Yes. Any value placed between curly braces, such as {name}, is rendered as text and HTML-escaped automatically. Characters like < and > become &lt; and &gt;, so inserting user input into JSX does not create an HTML or script injection risk by itself.