React and TypeScript are powerful frameworks for building scalable, maintainable, and safe websites. React provides a flexible and component-based architecture, while TypeScript adds static typing to JavaScript, for clean and readable code. This article will guide you through setting up a simple website with React and TypeScript, covering the core concepts needed to get started.
Why Choose React with TypeScript?
TypeScript is popular among JavaScript developers because it can catch errors during development and make code easier to understand and refactor. The two are ideal for building modern, fast websites and applications with maintainable code that scales well.
** Check out the whole code on GitHub: https://github.com/alexiacismaru/techtopia/tree/main/frontend
Basic React concepts and how to use them to build a website
Let’s build a website for a fictional amusement park called Techtopia. We will show elements like attractions and where they are on the map, a landing page, or a loading page. In addition, we will also make it possible to add/delete elements of the page or to search for them based on a variable.
Setup
Create an empty React project by copying this into the terminal.
npm create vite@latest reactproject --template react-ts
Then run the empty project and a new tab will open in the browser window.
cd reactproject
npm run dev
Final project structure overview
reactproject/
├── node_modules/
├── public/
├── src/
│ ├── assets/
│ ├── components/
│ ├── context/
│ ├── hooks/
│ ├── model/
│ ├── services/
│ ├── App.css
│ ├── App.tsx
│ ├── index.css
│ ├── vite-env.d.ts
├── .gitignore
├── package.json
└── tsconfig.json
Components
Components are elements of a webpage that can also be reused. They can be a part of the webpage, like the header or footer, or the entire page, like a list of users. It is just like a JavaScript function but returns a rendered element.
export function Header() {
return (
<header style={{ display: 'block', width: '100%', top: 0, left: 0, zIndex: 'var(--header-and-footer)' }}>
<div style={{
borderBottom: '1px solid white',
boxShadow: '',
backgroundColor: 'transparent',
paddingLeft: '1rem',
paddingRight: '1rem',
marginLeft: 'auto',
marginRight: 'auto',
}}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
padding: '5px',
alignItems: 'baseline',
}}
>
<a href='/techtopia' style={{
fontSize: '40px', fontFamily: 'MAROLLA__', color: 'black',
fontWeight: 'bold',
}}>Techtopia</a>
<div style={{display: 'flex',
justifyContent: 'space-around',
padding: '5px',
alignItems: 'baseline',}}>
<a href='/refreshment-stands' style={{
marginRight: '10px', color: 'black'
}}>Refreshment stands</a>
<a href='/attractions' style={{ marginRight: '10px', color: 'white'
}}>Attractions</a>
<a href='/map' style={{ marginRight: '60px', color: 'white'
}}>Map</a>
</div>
</div>
</div>
</header>
)
}
JSX
JSX is JavaScript XML, allowing the user to write HTML-like code in .jsx files.
<Button sx={{padding: "10px", color: 'black'}} onClick={onClose}>X</Button>
TSX
TSX is a file extension for TypeScript files that contains JSX syntax. With TSX you can write type-checked code with the existing JSX syntax.
interface RefreshmentStand {
id: string;
name: string;
isOpen: boolean;
}
const Reshfresment = (props: RefreshmentStand) => {
return (
<div>
<h1>{props.name}</h1>
<p>{props.isOpen}</p>
</div>
);
};
Fragments
Fragments return multiple elements to a component. It groups the list of elements without creating extra DOM nodes.
We can use them to fetch the data from a Java backend (check out how to build the Java application from this article: https://medium.com/@alexia.csmr/using-bounded-contexts-to-build-a-java-application-1c7995038d30). Start by installing Axios and using the base backend URL from your application. Then, we will create a fragment that uses GET to fetch all the attractions.
import axios from 'axios'
import { POI } from '../model/POI'
const BACKEND_URL = 'http://localhost:8093/api'
export const getAttractions = async () => {
const url = BACKEND_URL + '/attractions'
const response = await axios.get<POI[]>(url)
return response.data
}
This can be expanded to getting data based on parameters, POST, DELETE, etc.
export const addAttraction = async (attractionData: Omit<POI, 'id'>) => {
const url = BACKEND_URL + '/addAttraction'
const response = await axios.post(url, attractionData)
return response.data
}
export const getAttraction = async (attractionId: string) => {
const url = BACKEND_URL + '/attractions'
const response = await axios.get<POI>(`${url}/${attractionId}`)
return response.data
}
export const getAttractionByTags = async (tags: string) => {
const url = BACKEND_URL + '/attractions'
const response = await axios.get<POI[]>(`${url}/tags/${tags}`)
return response.data
}
State
The state is a React object that contains data or information about the component. A component’s state can change over time and when it does, the component re-renders.
To get a single element from a list based on a parameter you can use the useParams() hook.
const { id } = useParams()
const { isLoading, isError, attraction } = useAttraction(id!)
const { tag } = useParams()
const { isLoadingTag, isErrorTag, attractions } = useTagsAttractions(tag!)
Hooks
As seen above, I’ve used_ useAttractions() and _useTagsAttractions(). They are hooks and can be personalized to get any data you want. In this example, they fetch the attractions based on their ID _or _tags. Hooks can only be called inside React function components, can only be called at the top level of a component, and can’t be conditional.
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {POI} from "../model/./POI.ts";
import { addAttraction, getAttractions } from '../services/API.ts'
import { useContext } from 'react'
export function useAttractions() {
const queryClient = useQueryClient()
const {
isLoading: isDoingGet,
isError: isErrorGet,
data: attractions,
} = useQuery({
queryKey: ['attractions'],
queryFn: () => getAttractions(),
})
const {
mutate,
isLoading: isDoingPost,
isError: isErrorPost,
} = useMutation((item: Omit<POI, 'id'>) => addAttraction(item), {
onSuccess: () => {
queryClient.invalidateQueries(['attractions'])
},
});
return {
isLoading: isDoingGet || isDoingPost,
isError: isErrorGet || isErrorPost,
attractions: attractions || [],
addAttraction: mutate
}
}
isLoading and isError
For a better UI experience, it’s good to let the user know what’s happening i.e. the elements are loading, or there has been an error when doing so. They are first declared in the hook and then introduced in the component.
const navigate = useNavigate()
const { isLoading, isError, attractions, addAttraction } = useAttractions()
if (isLoading) {
return <Loader />
}
if (isError) {
return <Alert severity='error'>Error</Alert>
}
You can also create a separate Loader or Alert component for a more customized website.
export default function Loader() {
return (
<div>
<img alt="loading..."
src="https://media0.giphy.com/media/RlqidJHbeL1sPMDlhZ/giphy.gif?cid=6c09b9522vr2magrjgn620u5mfz1ymnqhpvg558dv13sd0g8&ep=v1_stickers_related&rid=giphy.gif&ct=s"/>
<h3>Loading...</h3>
</div>
)
}
Now, when the page is loading the user will see a special animation on the screen.
Mapping items (Lists and Keys)
If you want to display all the elements in a list then you need to map through all of them.
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAttractions } from '../hooks/usePOI.ts'
import { POI } from '../model/./POI.ts'
export default function Attractions() {
const navigate = useNavigate()
const { isLoading, isError, attractions, addAttraction } = useAttractions()
return (
<div style={{ marginTop: '70px' }}>
{filteredAttractions
.map(({ id, name, image }: POI) => (
<div onClick={() => navigate(`/attractions/${id}`)} >
<div>
<img src={image} alt={name}/>
<h3>{name}</h3>
</div>
</div>
))}
</div>
)
}
Create a separate file where you declare the Attraction element and its variables.
// ../model/POI.ts
export interface POI {
id: string;
name: string;
description: string;
tags: string;
ageGroup: string;
image: string;
}
More here you can create a type to later add more attractions using a form:
export type CreatePOI = Omit<POI, 'id'>; # id is automatically generated so we don't need to manually add it
Adding items
We already created the fragments and hooks needed for this, so now we can make a form where the user can write the attributes and add a new attraction to the webpage. This form was created using the MUIframework. First I’ll show the whole code and explain it in sections.
import {CreatePOI} from "../model/./POI.ts";
import {z} from 'zod';
import {zodResolver} from "@hookform/resolvers/zod";
import {Controller, useForm} from "react-hook-form";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
} from '@mui/material'
interface AttractionDialogProps {
isOpen: boolean;
onSubmit: (attraction: CreatePOI) => void;
onClose: () => void;
}
const itemSchema: z.ZodType<CreatePOI> = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
description: z.string(),
tags: z.string(),
ageGroup: z.string(),
image: z.string().url(),
})
export function AddAttractionDialog({isOpen, onSubmit, onClose}: AttractionDialogProps) {
const {
handleSubmit,
control,
formState: {errors},
} = useForm<CreatePOI>({
resolver: zodResolver(itemSchema),
defaultValues: {
name: '',
description: '',
tags: '',
ageGroup: '',
image: '',
},
});
return (
<Dialog open={isOpen} onClose={onClose}>
<form
onSubmit={handleSubmit((data) => {
onSubmit(data)
onClose()
})}
>
<div>
<DialogTitle>Add attraction</DialogTitle>
<Button onClick={onClose}>
X
</Button>
</div>
<DialogContent>
<Box>
<Controller
name="name"
control={control}
render={({field}) => (
<TextField
{...field}
label="Name"
error={!!errors.name}
helperText={errors.name?.message}
required
/>
)}
/>
<Controller
name="description"
control={control}
render={({field}) => (
<TextField
{...field}
label="Description"
error={!!errors.description}
helperText={errors.description?.message}
/>
)}
/>
<Controller
name="tags"
control={control}
render={({field}) => (
<TextField
{...field}
label="Tags"
error={!!errors.tags}
helperText={errors.tags?.message}
required
/>
)}
/>
<Controller
name="ageGroup"
control={control}
render={({field}) => (
<TextField
{...field}
label="Age group"
error={!!errors.ageGroup}
helperText={errors.ageGroup?.message}
required
/>
)}
/>
<Controller
name="image"
control={control}
render={({field}) => (
<TextField
{...field}
label="Image"
error={!!errors.image}
helperText={errors.image?.message}
required
/>
)}
/>
</Box>
</DialogContent>
<DialogActions>
<Button type="submit" variant="contained">
Add
</Button>
</DialogActions>
</form>
</Dialog>
)
}
If you want to make the form a pop-up instead of a separate page, add isOpen() and isClosed() attributes. onSubmit() is mandatory since this will trigger the createPOI() function and add a new object to the list.
interface AttractionDialogProps {
isOpen: boolean;
onSubmit: (attraction: CreatePOI) => void;
onClose: () => void;
}
For user form validation we will install and import Zod. Here declare what format the input needs to be and if there are any requirements like minimum or maximum length.
const itemSchema: z.ZodType<CreatePOI> = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
description: z.string(),
tags: z.string(),
ageGroup: z.string(),
image: z.string().url(),
})
Inside the component, we need to implement the submit and the user validation.
const {
handleSubmit,
control,
formState: {errors},
} = useForm<CreatePOI>({
resolver: zodResolver(itemSchema),
defaultValues: {
name: '',
description: '',
tags: '',
ageGroup: '',
image: '',
},
});
The errors will be implemented in the TextField of the form with any other attributes.
<TextField
{...field}
label="Name"
error={!!errors.name}
helperText={errors.name?.message}
required
/>
Make sure that the form can be closed and submitted in the beginning.
<Dialog open={isOpen} onClose={onClose}>
<form
onSubmit={handleSubmit((data) => {
onSubmit(data)
onClose()
})}
>
</form>
</Dialog>
You can implement this pop-up in another component.
import { Fab } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
<Fab
size='large'
aria-label='add'
onClick={() => setIsDialogOpen(true)}
>
<AddIcon />
</Fab>
Deleting items
Create a hook that uses DELETE and implement it in a component.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { deleteRefreshmentStand, getRefreshmentStand } from '../services/API.ts'
import { useContext } from 'react'
export function useRefreshmentStandItem(refreshmentStandId: string) {
const queryClient = useQueryClient()
const {
isLoading: isDoingGet,
isError: isErrorGet,
data: refreshmentStand,
} = useQuery({
queryKey: ['refreshmentStand'],
queryFn: () => getRefreshmentStand(refreshmentStandId),
})
const deleteRefreshmentStandMutation = useMutation(() => deleteRefreshmentStand(refreshmentStandId), {
onSuccess: () => {
queryClient.invalidateQueries(['refreshmentStands']);
},
});
const handleDeleteRefreshmentStand = () => {
deleteRefreshmentStandMutation.mutate(); // Trigger the delete mutation
};
return {
isLoading: isDoingGet || deleteRefreshmentStandMutation.isLoading,
isError: isErrorGet || deleteRefreshmentStandMutation.isError,
refreshmentStand,
deleteRefreshmentStand: handleDeleteRefreshmentStand,
};
}
export default function RefreshmentStand() {
const { id } = useParams()
const { isLoading, isError, refreshmentStand, deleteRefreshmentStand } = useRefreshmentStandItem(id!)
if (isLoading) {
return <Loader />
}
if (isError || !refreshmentStand) {
return <Alert severity='error'>Error</Alert>
}
return (
<>
<CardMedia component='img' image={background} alt='background' />
<AuthHeader />
<div style={{ display: 'flex', alignItems: 'center' }}>
<div>
<h1>{refreshmentStand.name}</h1>
<p>Status: {refreshmentStand.isOpen ? 'Open' : 'Closed'}</p>
/* implement the delete button */
<Fab>
<DeleteIcon onClick={deleteRefreshmentStand}/>
</Fab>
</div>
<img src={refreshmentStand.image} alt='refreshmentStand image' />
</div>
<Footer />
</>
)
}
Filtering items
Inside the component create a toggle for the filter text input and a constant that filters the attractions based on the age group or tags. Optional chaining (?) ensures it handles null or undefined values without errors.
const toggleFilter = () => {
setIsFilterOpen(!isFilterOpen)
}
const filteredAttractions = attractions
.filter((attraction: POI) =>
attraction.ageGroup?.toLowerCase().includes(ageGroupFilter.toLowerCase()),
)
.filter((attraction: POI) =>
attraction.tags?.toLowerCase().includes(tagsFilter.toLowerCase()),
)
Include it when iterating through the list of items.
{filteredAttractions
.filter((attraction: POI) =>
searchText.trim() === '' ||
attraction.name.toLowerCase().includes(searchText.toLowerCase()),
)
.map(({ id, name, image }: POI) => (
<div>
<div>
<img
src={image}
alt={name}
/>
<h3>{name}</h3>
</div>
</div>
))}
Conclusion
Using React with TypeScript enables you to build dynamic, safe websites that are easy to maintain and scale. TypeScript’s type-checking prevents runtime errors, while React’s component-based structure organizes the project efficiently.