How to buildOctober 11, 20269 min read
ReactStep 2 of 8

React: components, JSX and props

In short

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 &&.

TutorialLevel: BeginnerReactPractice: ~45–90 min

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 map and a stable key, and explain why the index makes a poor key
  • Show content conditionally with if, ? : and && without the 0 trap, and verify it with a test

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
  1. 01Why React: building UI from components
  2. 02Creating a project with Vite
  3. 03Components and JSX: markup rules
  4. 04Props and children
  5. 05Lists and keys
  6. 06Conditional rendering
  7. 07Checking: build, tests and practice

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.

bash
npm create vite@latest services-catalog -- --template react-ts
cd services-catalog
npm install
npm run dev
The dev server opens at http://localhost:5173. The --no-interactive flag skips the wizard's questions.

The 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.

src/main.tsx
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>,
)
Unchanged from the template. StrictMode enables extra checks in development; more on it in the useEffect lesson.

index.html

Purpose
a page with an empty <div id="root"> that loads src/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.

src/data/services.ts
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.

src/components/ServiceCard.tsx
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
className instead of class
Why
attributes become object keys, and class is a reserved word. aria-* and data-* 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, not serviceCard
Why
the name is how React tells your <ServiceCard /> component from an HTML tag like <section>
Rules from react.dev: Your First Component and Writing Markup with JSX.

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: Service is a required prop: TypeScript won't let you render the card without it.
  • currency?: string has a default of '₸'. According to react.dev, the default is only used if the prop is missing or undefined; null and 0 count as passed values.
  • const { title, … } = service destructures 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.

src/Mistakes.tsx
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} />
    </>
  )
}
A practice file with deliberate errors; delete it after the check.
terminal
$ 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.

src/components/Section.tsx
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.

src/App.tsx
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 App
src/components/ServiceList.tsx
import 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:

terminal
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.
A React development-mode warning, shown in the browser console or in the test output.

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 feature in 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

src/components/ServiceCard.tsx
{features.length && (
A buggy fragment, for the experiment only.
terminal
$ 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)
Output shortened. The third node inside the card is that “0” text.

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.

bash
npm install -D vitest jsdom @testing-library/react @testing-library/dom
vite.config.ts
/// <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'],
  },
})
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()
})
src/App.test.tsx
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.

terminal
$ 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)
Your file hashes and build time will differ.

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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.