A React component is a JavaScript function with a capitalised name that returns JSX markup. Data reaches a component through props, an object of parameters the component reads but never changes. Lists are rendered with map, giving each item a unique key, and conditional content uses plain if, ? : and &&.
After this you will be able to
- Create a React + TypeScript project with Vite and explain what each template file does
- Write a function component with typed props, a default value and
children - Render a list with
mapand a stablekey, and explain why the index makes a poor key - Show content conditionally with
if,? :and&&without the0trap, and verify it with a test
Know beforehand
Verified with
React 19.3.0TypeScript 6.0.3TypeScript 7.0.2Vite 8.3.4Vitest 5.0.3@testing-library/react 16.3.3Node.js 24 LTS (24.18.0)
Checked against docs: October 11, 2026
Contents
Why React: building UI from components
In the DOM lesson we found elements on the page and changed them by hand: add a card, hide a block, update some text. The bigger the interface, the easier it is to forget one of those changes. React takes a different approach: you describe what the UI should look like for the current data, and the library brings the page into that state.
The UI is built from components. As react.dev defines it, a component is a JavaScript function that you can sprinkle with markup. A button, a service card, a catalogue section, the whole page: they're all components, and components render other components.
In this lesson we'll build a beauty salon's service catalogue: sections by category, each with cards rendered from an array of data. A card shows the name, duration, a price or an “after consultation” note, a list of what's included and a “New” badge. A category with no services gets a clear placeholder. Everything is in TypeScript: the types from the previous lesson will describe what data each component accepts.
Creating a project with Vite
Vite is a build tool: it runs a development server that updates the page instantly and bundles the project for production. The Vite docs require Node.js 20.19+ or 22.12+; we're using Node.js 24 LTS. Let's create a project from the official react-ts template, with React and TypeScript already set up.
npm create vite@latest services-catalog -- --template react-ts
cd services-catalog
npm install
npm run devThe template creates index.html, a src folder and the TypeScript configuration. The entry point is src/main.tsx: it finds the #root element in the HTML and renders the root App component into it. According to the React docs, files containing JSX must use the .tsx extension.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)index.html
- Purpose
- a page with an empty
<div id="root">that loadssrc/main.tsx
src/main.tsx
- Purpose
- entry point: creates the React root and renders
App
src/App.tsx
- Purpose
- the root component, which we'll replace with the catalogue
tsconfig.app.json
- Purpose
- TypeScript settings for the application code
vite.config.ts
- Purpose
- Vite and React plugin configuration
We won't need the template's demo content: delete src/assets and src/App.css, and replace src/index.css with your own styles, which don't matter for this lesson. At the time of checking, the template installs React 19.3, Vite 8.3 and TypeScript 6.0; the project also passes the TypeScript 7.0 compiler.
Components and JSX: markup rules
Let's start with the data. It's a plain TypeScript module: a service type, a list of categories and an array of services. Later the data will come from a server, but the components won't care: they receive ready-made objects.
export type Category = 'hair' | 'nails' | 'brows'
export interface Service {
id: string
title: string
category: Category
durationMin: number
priceFrom?: number // no price means “after consultation”
features: string[]
isNew?: boolean
}
export const categories: { id: Category; title: string }[] = [
{ id: 'hair', title: 'Hair' },
{ id: 'nails', title: 'Nails' },
{ id: 'brows', title: 'Brows' },
]
export const services: Service[] = [
{
id: 'haircut',
title: 'Women’s haircut',
category: 'hair',
durationMin: 60,
priceFrom: 9000,
features: ['Stylist consultation', 'Wash and blow-dry'],
},
{
id: 'coloring',
title: 'Colouring',
category: 'hair',
durationMin: 180,
features: ['Shade matching', 'Aftercare treatment'],
isNew: true,
},
{
id: 'manicure',
title: 'Gel manicure',
category: 'nails',
durationMin: 90,
priceFrom: 7000,
features: [],
},
]Now the card. A component is a function that returns JSX: HTML-like markup inside JavaScript.
import type { Service } from '../data/services.ts'
interface ServiceCardProps {
service: Service
currency?: string
}
export function ServiceCard({ service, currency = '₸' }: ServiceCardProps) {
const { title, durationMin, priceFrom, features, isNew } = service
return (
<article className="card">
<h3>
{title}
{isNew && <span className="badge">New</span>}
</h3>
<p className="meta">
{durationMin} min ·{' '}
{priceFrom !== undefined
? `from ${priceFrom} ${currency}`
: 'price after consultation'}
</p>
{features.length > 0 && (
<ul>
{features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
)}
</article>
)
}A single root element
- In the card
- everything is wrapped in
<article> - Why
- JSX turns into JavaScript objects, and a function returns one value. If you don't need a wrapper, use a fragment
<>…</>
All tags are closed
- In the card
<li>…</li>; self-closing:<img />- Why
- JSX syntax requires it
camelCase attributes
- In the card
classNameinstead ofclass- Why
- attributes become object keys, and
classis a reserved word.aria-*anddata-*are the exception
Expressions in curly braces
- In the card
{title},{durationMin} min- Why
- any JavaScript expression goes inside
{}: a variable, a call, a ternary
Capitalised name
- In the card
ServiceCard, notserviceCard- Why
- the name is how React tells your
<ServiceCard />component from an HTML tag like<section>
If the markup doesn't start on the same line as return, wrap it in parentheses as in the example. Without them, JavaScript ends the return at the line break and the component returns nothing.
Props and children
Props are the data a parent passes to a component through JSX attributes: <ServiceCard service={service} />. The component receives them as a single object, the function's first parameter. That object is usually destructured right away: ({ service, currency = '₸' }). Props are described with an ordinary interface.
service: Serviceis a required prop: TypeScript won't let you render the card without it.currency?: stringhas a default of'₸'. According to react.dev, the default is only used if the prop is missing orundefined;nulland0count as passed values.const { title, … } = servicedestructures inside the function, so the markup uses short names.
Let's see what typing buys us. Put a practice file with three errors in src and run the type check that npm run build performs.
import { ServiceCard } from './components/ServiceCard.tsx'
import { services } from './data/services.ts'
export function Mistakes() {
return (
<>
<ServiceCard />
<ServiceCard service={services[0]} curency="KZT" />
<ServiceCard service={services[0]} currency={398} />
</>
)
}$ npx tsc -b
src/Mistakes.tsx(7,8): error TS2741: Property 'service' is missing in type '{}' but required in type 'ServiceCardProps'.
src/Mistakes.tsx(8,42): error TS2322: Type '{ service: Service; curency: string; }' is not assignable to type 'IntrinsicAttributes & ServiceCardProps'.
Property 'curency' does not exist on type 'IntrinsicAttributes & ServiceCardProps'. Did you mean 'currency'?
src/Mistakes.tsx(9,42): error TS2322: Type 'number' is not assignable to type 'string'.A missing required prop, a misspelled name (TypeScript even suggests the right one) and a value of the wrong type are all caught before anything runs. In a JavaScript project, all three would show up only in the browser, or not at all, with the card silently showing undefined.
children is a special prop. According to the docs, when you nest content inside a component's tag, the parent component receives it in a prop called children. A component with children is like a frame with a “hole” the parent fills in. The ReactNode type allows any renderable content: elements, strings, numbers, lists.
import type { ReactNode } from 'react'
interface SectionProps {
title: string
children: ReactNode
}
export function Section({ title, children }: SectionProps) {
return (
<section className="section">
<h2>{title}</h2>
{children}
</section>
)
}Lists and keys
React renders lists with ordinary array methods: filter picks the items you need and map turns each one into JSX. App has two levels of lists: sections by category, and inside each, the cards for that category.
import { Section } from './components/Section.tsx'
import { ServiceList } from './components/ServiceList.tsx'
import { categories, services } from './data/services.ts'
function App() {
return (
<main>
<h1>Salon services</h1>
{categories.map((category) => (
<Section key={category.id} title={category.title}>
<ServiceList
items={services.filter((service) => service.category === category.id)}
/>
</Section>
))}
</main>
)
}
export default Appimport type { Service } from '../data/services.ts'
import { ServiceCard } from './ServiceCard.tsx'
export function ServiceList({ items }: { items: Service[] }) {
if (items.length === 0) {
return <p className="empty">No services in this category yet.</p>
}
return (
<div className="grid">
{items.map((service) => (
<ServiceCard key={service.id} service={service} />
))}
</div>
)
}Every element created by map needs a key, a string or number that's unique among its siblings. According to react.dev, the key tells React which array item each component corresponds to, so it can update the page correctly when items are inserted, removed or reordered. Here's what the console shows if you remove key from the cards:
stderr | src/App.test.tsx > service catalogue > renders one card per service in the array
Each child in a list should have a unique "key" prop.
Check the render method of `ServiceList`. See https://react.dev/link/warning-keys for more information.An id from the database or API
- Suitable?
- yes
- Why
- unique and stable
A value unique within the list
- Suitable?
- yes, with a caveat
- Why
- like
featurein the card: works as long as the strings don't repeat
crypto.randomUUID() when creating a record
- Suitable?
- yes
- Why
- for data created in the browser: once, at creation time, not during rendering
The array index
- Suitable?
- only for a list that never changes
- Why
- on insert, delete or sort the index moves to a different item; the docs warn this leads to subtle bugs
Math.random() in the markup
- Suitable?
- no
- Why
- the key changes on every render, so React recreates the elements and loses user input
The key is for React, not for the component: ServiceCard won't receive key in its props. If a component needs an identifier, pass it as a separate prop. One more detail from the docs: an arrow function in map with curly braces needs an explicit return, otherwise the list comes out empty.
Conditional rendering
Conditions in JSX are plain JavaScript. The catalogue uses three techniques, each where it fits best.
if with an early return
- Where in the catalogue
ServiceList: an empty category → placeholder- When to use it
- when the whole component looks different
The ternary operator ? :
- Where in the catalogue
- price:
from 9000 ₸or “price after consultation” - When to use it
- choosing between two options inside markup
Logical &&
- Where in the catalogue
- the “New” badge, the list of inclusions
- When to use it
- show something or nothing
For &&, the React docs give an important warning: don't put a number on the left. If features is empty, features.length && … evaluates to 0, and React renders that zero on the card: it skips false, null and undefined, but numbers get rendered. That's why the card's condition is written as features.length > 0. The test catches this bug; change the condition to
{features.length && ($ npx vitest run
FAIL src/App.test.tsx > service catalogue > renders the badge, price and list conditionally
AssertionError: expected …(3) to have a length of 2 but got 3
Tests 1 failed | 2 passed (3)When conditions pile up, the docs suggest not stacking ternaries in the markup but computing the content beforehand: declare a variable with let, assign it JSX inside an if and insert {content}. A component can also return null, but react.dev notes this is uncommon: the condition usually lives in the parent.
Checking: build, tests and practice
Let's make sure the catalogue works by more than eyeballing it. Install Vitest, a test runner that uses the Vite configuration, plus jsdom for a DOM in Node.js and React Testing Library for rendering components in tests.
npm install -D vitest jsdom @testing-library/react @testing-library/dom/// <reference types="vitest/config" />
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// without globals: true, Testing Library does not clean up the DOM itself, so we do it after each test
afterEach(() => {
cleanup()
})import { render, screen, within } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import App from './App.tsx'
import { ServiceCard } from './components/ServiceCard.tsx'
describe('service catalogue', () => {
it('renders one card per service in the array', () => {
render(<App />)
expect(screen.getAllByRole('article')).toHaveLength(3)
})
it('shows a placeholder for a category with no services', () => {
render(<App />)
expect(screen.getByText('No services in this category yet.')).toBeTruthy()
})
it('renders the badge, price and list conditionally', () => {
render(
<ServiceCard
service={{
id: 'test',
title: 'Test service',
category: 'nails',
durationMin: 30,
features: [],
isNew: true,
}}
/>,
)
const card = screen.getByRole('article')
expect(within(card).getByText('New')).toBeTruthy()
expect(within(card).getByText('30 min · price after consultation')).toBeTruthy()
expect(within(card).queryByRole('list')).toBeNull()
expect(card.childNodes).toHaveLength(2) // h3 and p, no stray “0”
})
})Add a "test": "vitest run" script to package.json. npm run build type-checks first (tsc -b) and then bundles the project; npm test runs the tests once.
$ npm run build
> services-catalog@0.0.0 build
> tsc -b && vite build
vite v8.3.4 building client environment for production...
transforming...
✓ 20 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.46 kB │ gzip: 0.29 kB
dist/assets/index-DLgvb7S-.css 0.41 kB │ gzip: 0.27 kB
dist/assets/index-CT4aR44L.js 221.12 kB │ gzip: 69.42 kB
✓ built in 126ms
$ npm test
> services-catalog@0.0.0 test
> vitest run
RUN v5.0.3 …/services-catalog
Test Files 1 passed (1)
Tests 3 passed (3)The catalogue is static for now: whatever comes in through props is what gets rendered. Next on the track: state and useState. We'll add a category filter, a cart and input fields, and the UI will start responding to the user.
How this was verified6 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.