React · lesson 1 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5Also 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
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, <!-- --><script>alert(1)</script></p>
The angle brackets come out as < and >, not as a real <script> tag. JSX escapes every value placed in {} before it becomes HTML.