Skip to main content

React.js with Typescript 2023

TST, Hongkong

Foundation

Typescript Basics

Type Declaration

Assigning types when declaring variables:

let flag: boolean;
const numbers: number[] = [];
let lastOrder: Date | null;
lastOrder = new Date();

In objects optional variables can be assigned types using a ?:

const productA: { name: string; unitPrice?: number } = {
name: "Product A",
};

Handling types in functions:

function getTotal(
unitPrice: number,
quantity: number,
discount: number
): number {
const priceWithoutDiscount = unitPrice * quantity;
const discountAmount = priceWithoutDiscount * discount;
return priceWithoutDiscount - discountAmount;
}

Use unknown instead of any to be able to widen the type once it's type becomes defined:

fetch("https://swapi.dev/api/people/1")
.then((response) => response.json())
.then((data: unknown) => {
if (isCharacter(data)) {
console.log("name", data.name);
}
});

function isCharacter(
character: any
): character is { name: string } {
return "name" in character;
}

Use void instead of undefined for functions that don't have a return statement:

function logText(text: string): void {
console.log(text);
}

Function with unreachable return statements should use never instead:

function taskLoop(taskName: string): never {
while (true) {
console.log(`${taskName} is running...`);
}
}

Working with classes:

class Product {
constructor(public name: string, public unitPrice: number) {
this.name = name;
this.unitPrice = unitPrice;
}
getDiscountedPrice(discount: number): number {
return this.unitPrice - discount;
}
}

const productA = new Product("Product A", 45);
console.log(productA.getDiscountedPrice(5));

Aliases

To clean-up the type declaration we can use aliases:

type Product = { name: string; unitPrice?: number };
type DiscountedProduct = Product & { discount: number };

let productA: Product = { name: "Product A" };
let productB: DiscountedProduct = { name: "Product B", unitPrice: 299, discount: 15 };

Using type aliases to represent functions:

type Purchase = (quantity: number) => void;

type Product = {
name: string;
unitPrice?: number;
purchase: Purchase;
};
let productA: Product = {
name: "Product A",
purchase: (quantity) =>
console.log(`${quantity} Product A sold.`),
};

table.purchase(4);

Interfaces

Interfaces instead of type aliases:

interface Product {
name: string;
unitPrice?: number;
}

interface DiscountedProduct extends Product {
discount: number;
}
interface Purchase {(quantity: number): void}

interface Product {
name: string;
unitPrice?: number;
purchase: Purchase;
}

let productA: Product = {
name: "Product A",
purchase: (quantity) =>
console.log(`${quantity} Product A sold.`),
};

productA.purchase(4);

Enumerations

You can use union types to declare sets of allowed names:

type Colour = "red" | "green" | "blue";

let colour: Colour = "red";
console.log(colour);

Enumerations allow the declaration of sets like:

enum Level {
Info,
Warning,
Error
}

let level = Level.Info;
console.log(level);

let level = Level[1];
console.log(level);
enum Level {
Low = "L",
Medium = "M",
High = "H"
}

let level = Level.High;
console.log(level);

React Typescript

Effect Hook

The effect Hook is used to execute component side effects when a component is rendered or when certain props or states change.

import { useEffect, useState } from "react";

export function EffectHookClick() {

const [clicked, setClicked] = useState(false);

useEffect(() => {
if (clicked) {
console.log("INFO :: Effect Hooked");
}
}, [clicked]);

function handleClick() {
setClicked(true);
}
return <button onClick={handleClick}>Cause effect</button>;
}
function EffectHookCondition({someProp}) {
useEffect(() => {
if (someProp) {
console.log("Some effect");
}
});
if (!someProp) {
return null
}
return
}
function EffectHookReturnFunction({ onClickAnywhere }) {
useEffect(() => {

function handleClick() {
onClickAnywhere();
}

document.addEventListener("click", listener);

return () => {
document.removeEventListener("click", listener);
};
});
return
}

Data Fetching

A common use case for the effect Hook is fetching data:

import { useEffect } from 'react';

type Person = {
name: string,
};

export function simAPIRequest(): Promise<Person> {
return new Promise((resolve) =>
setTimeout(() => resolve({ name: "George González" }), 1000)
);
}

export function DisplayAPIResponse() {
useEffect(() => {
simAPIRequest().then((person) => console.log(person));
}, []);

return null;
}

State Hook

State hooks can be used to store and update state. E.g. you can write the API response from above into a state variable:

export function DisplayAPIResponse()  {
const [name, setName] = useState<string | undefined>();
const [score, setScore] = useState(0);
const [loading, setLoading] = useState(true);

useEffect(() => {
simAPIRequest().then((person) => {
setLoading(false);
setName(person.name);
});
}, []);

if (loading) {
return <div>Loading ...</div>;
}

return (
<div>
<h3>{name}, {score}</h3>
<button onClick={() => setScore(score + 1)}>Add</button>
<button onClick={() => setScore(score - 1)}>Subtract</button>
<button onClick={() => setScore(0)}>Reset</button>
</div>
);
}

Ref Hook

useRef returns a variable whose value is persisted for the lifetime of a component:

const ref = useRef<Ref>(initialValue);
ref.current = newValue;
console.log("Current ref value", ref.current);
import { useRef } from "react";

export function InputComponent() {
const inputRef = useRef<HTMLInputElement>(null);

function logInput() {
console.log(inputRef.current);
}
return <input ref={inputRef} onChange={logInput} type="text" />;
}

Use a ref hook to focus a HTML element after a component is loaded:

const addButtonRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
if (!loading) {
addButtonRef.current?.focus();
}
}, [loading]);

return (
<button ref={addButtonRef}>Add</button>
);

Memo Hook

Memo Hooks can be used to store values that have computationally expensive calculations. E.g. the following value is recalculated every time the variable a or b change:

import { useMemo } from 'react';

const memoValue = useMemo<number>(
() => thisWillTakeAWhile(a, b),
[a, b]
);

As long as a and b don't change the calculation is only done once when the component is loaded:

function calculateScore() {
console.log("INFO :: Score is being calculated.");
let sum = 0;
for (let i = 0; i < 10000; i++) {
sum += i;
}
return sum;
}

const getScore = useMemo(
() => calculateScore(),
[]
);

return (
<p>{getScore}</p>
);

Callback Hook

While the Memo Hook is used to cache values the Callback Hook holds an entire function. The memo function wraps the component and memoizes the result for a given set of props preventing unnecessary re-rendering of slow components:

const memoizedValue = useCallback< () => void > (
() => someFunction (),
[]
);

Styling React

Vanilla CSS

import './App.css';

function App() {
return (
<div className="App">
...
</div>
);
}

Tailwind CSS

Install tailwindcss and its peer dependencies, then generate your tailwind.config.js and postcss.config.js files:

npm install -D tailwindcss postcss autoprefixer

npx tailwindcss init -p

Created Tailwind CSS config file: tailwind.config.js
Created PostCSS config file: postcss.config.js

And add the following code to the tsconfig.json file to resolve paths:

{
"compilerOptions": {
// ...
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
// ...
}
}

Add the following code to the vite.config.ts so your app can resolve paths without error:

# (so you can import "path" without error)
npm i -D @types/node
npm i @vitejs/plugin-react
import path from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"

export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})

ShadCN UI

Run the shadcn-ui init command to setup your project:

npx shadcn-ui@latest init

You will be asked a few questions to configure components.json:

Would you like to use TypeScript (recommended)? no / yes
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › › src/index.css
Do you want to use CSS variables for colors? › no / yes
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/components
Configure the import alias for utils: › @/lib/utils
Are you using React Server Components? › no / yes (no)

You can now start adding components to your project.

npx shadcn-ui@latest add button

The command above will add the Button component to your project. You can then import it like this:

import { Button } from "@/components/ui/button"

export default function Home() {
return (
<div>
<Button>Click me</Button>
</div>
)
}

Importing SVGs

npm install --save-dev vite-plugin-svgr

Add the following to tsconfig.json:

{
"compilerOptions": {
"types": ["vite/client", "vite-plugin-svgr/client"],

And import the plugin to vite.config.ts:

import svgr from "vite-plugin-svgr";

export default defineConfig({
plugins: [
svgr()
],

You can now import SVGs like:

import AlertIcon from "@/assets/alert-triangle.svg?react";

...

return (
<AlertIcon />

React Router

npm i react-router-dom

Prepare a set of nested routes for your app. Here all pages will be rendered as children inside the App component:

@/routes/Routes.tsx

import {
createBrowserRouter,
RouterProvider,
Navigate
} from 'react-router-dom';

import { App } from '@/App'
import { FrontPage } from '@/pages/Frontpage';
import { CameraList } from '@/pages/Camera_List';

const router = createBrowserRouter(
[{
path: '/',
element: <App />,
errorElement: <ErrorPage />,
children: [
{
path: 'dashboard',
element: <FrontPage />,
},
{
path: 'camera-list',
element: <CameraList />,
},
{
path: '*',
element: <Navigate to="dashboard" replace />,
},
]}
]);

export function Routes() {
return <RouterProvider router={router} />;
}

The App component only contains an Outlet for the child components and components you want to be displayed on all pages - navigation bars, side navigation, etc.:

@/App.tsx

import { Outlet } from 'react-router-dom';
import { NavBar } from '@/components/NavBar'

export function App() {
return (
<>
<NavBar />
<Outlet />
</>
);
}

The routes are now imported into Main:

@/main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import { Routes } from '@/routes/Routes'
import { ThemeProvider } from "@/components/Theme-Provider"
import '@/styles/index.css'



ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<Routes />
</ThemeProvider>
</React.StrictMode>,
)

Route Parameter

Add another route that selects a single camera by ID:

@/routes/Routes.tsx

{
path: 'camera/:id',
element: <CameraPage />,
},

Mock an API request to retrieve the camera information:

@/data/cameras.ts

export type Camera = {
id: number;
name: string;
recordings: number;
ip: string;
};

export const cameras: Camera[] = [
{
id: 0,
name: 'IN-9420 2K+ WQHD',
recordings: 21,
ip: '192.168.2.21',
},
...

Retrieve camera ID from the URL parameter and find the camera in the list above to display its details:

@/pages/CameraPage.tsx

import { useParams, Link } from 'react-router-dom';

type Params = {
id: string;
};


export function CameraPage() {
const params = useParams<Params>();
// get camera ID from URL param
const id = params.id === undefined ? undefined : parseInt(params.id)
// find corresponding camera
const camera = cameras.find(
(camera) => camera.id === id
)

return (
<div className="text-center p-5 text-xl">
{camera === undefined ? (
<h1 className="text-xl">
ERROR :: Camera not available
</h1>
) : (
<>
<h1 className="text-xl">
Camera Name: {camera.name}
</h1>
<p className="text-base">
Camera IP: {camera.ip}
</p>
<p className="text-base">
Recordings: {camera.recordings}
</p>
</>
)}
</div>
)}

Lazy Loading

To lazy load 'heavy' pages add suspense to the route definition:

import { lazy, Suspense } from 'react'

import { App } from '@/App'
import { ErrorPage } from '@/pages/ErrorPage'
const Dashboard = lazy(() => import('@/pages/Dashboard'));

const router = createBrowserRouter(
[{
path: '/',
element: <App />,
errorElement: <ErrorPage />,
children: [
{
path: 'dashboard',
element: (
<Suspense fallback={
<div>
Loading...
</div>
}>
<Dashboard />
</Suspense>
)
}
...

React Router Form

import {
Form,
ActionFunctionArgs,
redirect,
} from 'react-router-dom';


import { Button } from "@/components/ui/button"

import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

type LoginType = {
name: string;
password: string;
}


export function LoginForm() {

return (
<div className='container mx-auto h-screen flex justify-center'>
<Card className="w-fit h-fit mt-16">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>Please use your user login to access your cameras.</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-8">
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="name">Username</Label>
<Input type="text" id="name" name="name" placeholder="Username" required />
</div>
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="password">Password</Label>
<Input type="password" id="password" name="password" placeholder="Password" required />
</div>
<div>
<Button type="submit">
Submit
</Button>
</div>
</Form>
</CardContent>
</Card>
</div>
);
}

export async function loginFormAction({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const login = {
name: formData.get('name'),
password: formData.get('password'),
} as LoginType;

console.log('Login details:', login);

return redirect(`/start/`);
}
import { App } from '@/App'
import { ErrorPage } from '@/pages/ErrorPage'

import { Welcome } from '@/pages/Welcome'
import { loginFormAction } from '@/components/welcome/Login'

const router = createBrowserRouter(
[{
path: '/',
element: <App />,
errorElement: <ErrorPage />,
children: [
{
index: true,
element: <Welcome />,
action: loginFormAction,
},
...

Form Hooks

npm install react-hook-form`

import { useForm, FieldError } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';

import { Button } from "@/components/ui/button"

import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

import { Textarea } from "@/components/ui/textarea"

import { ValidationError } from '@/components/create_user/ValidationError'

type UserType = {
name: string;
password: string;
email?: string;
level: string;
notes?: string;
}


export function UserForm() {

const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<UserType>({
mode: "onBlur",
reValidateMode: "onBlur"
})

const navigate = useNavigate()

function onSubmit(user: UserType) {
console.log('Submitted details:', user);
navigate(`/user-details/${user.name}`);
}

function getEditorStyle(fieldError: FieldError |
undefined) {
return fieldError ? 'border-red-500' : '';
}

return (
<div className='container mx-auto h-screen flex justify-center'>
<Card className="w-fit h-fit mt-4">
<CardHeader>
<CardTitle>Add a User</CardTitle>
<CardDescription>Fill out the username, password and access level to create a new user.</CardDescription>
</CardHeader>
<CardContent>
<form noValidate onSubmit={handleSubmit(onSubmit)}>
<div>
<Label htmlFor="name">Your user name</Label>
<Input
type="text"
id="name"
disabled={isSubmitting}
{...register('name', {
required: 'You must enter your name.',
})}
className={getEditorStyle(errors.name)}
/>
<ValidationError fieldError={errors.name} />
</div>
<div className='mt-4'>
<Label htmlFor="password" className='my-4'>Your user password</Label>
<Input
type="password"
id="password"
disabled={isSubmitting}
{...register('password', {
required: 'You must enter your password.',
})}
className={getEditorStyle(errors.password)}
/>
<ValidationError fieldError={errors.password} />
</div>
<div className='mt-4'>
<Label htmlFor="email" className='my-2'>Your email address</Label>
<Input
type="email"
id="email"
disabled={isSubmitting}
{...register('email', {
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Entered value does not match email format.',
},
})}
className={getEditorStyle(errors.email)}
/>
<ValidationError fieldError={errors.email} />
</div>
<div className='mt-4'>

<Label htmlFor="level" className='my-2'>Granted access level</Label>
<div className='flex flex-col'>
<select
id="level"
disabled={isSubmitting}
{...register('level', {
required: 'You must enter the authorization level.',
})}
className='flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-5'
>
<option value="">Select an authorization level...</option>
<option value="user">User</option>
<option value="operator">Operator</option>
<option value="administrator">Administrator</option>
</select>
<ValidationError fieldError={errors.level} />
</div>

</div>
<div className='mt-4'>
<Label htmlFor="notes">Additional notes</Label>
<Textarea id="notes" disabled={isSubmitting} {...register('notes')} />
</div>
<div className='mt-4'>
<Button type="submit">
Submit
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
import { FieldError } from 'react-hook-form';

type Props = {
fieldError: FieldError | undefined;
};

export function ValidationError({ fieldError }: Props) {

if (!fieldError) {
return null;
}

return (
<div role="alert" className="text-red-500 text-xs mt-1">
{fieldError.message}
</div>
);
}

Redux State Management

Store

Create a store using the Redux Toolkit:

@/store/store.tsx

import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';

export const store = configureStore({
reducer: {
user: userReducer,
camera: cameraReducer,
},
});

// ReturnType infers the type of the full state object
export type RootState = ReturnType<typeof store.getState>;

Every app feature has its own slice of the state accessible by its own reducer function:

@/store/userSlice.tsx

import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
// mocked API request to get current user - see below
import { User } from '@/api/authenticate'

type State = {
user: undefined | User;
permissions: undefined | string[];
loading: boolean;
}

const initialState: State = {
user: undefined,
permissions: undefined,
loading: false,
}

export const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
authenticateAction: (state) => {
state.loading = true;
},
authenticatedAction: (state, action: PayloadAction<User | undefined>) => {
state.user = action.payload;
state.loading = false;
},
authorizeAction: (state) => {
state.loading = true;
},
authorizedAction: (state, action: PayloadAction<string[]>) => {
state.permissions = action.payload;
state.loading = false;
},
},
})

export const { authenticateAction, authenticatedAction, authorizeAction, authorizedAction } =
userSlice.actions

export default userSlice.reducer

The Redux store needs to be provided to our React component tree:

@/App.tsx

import { Provider } from 'react-redux';

import { Outlet } from 'react-router-dom';
import { NavBar } from '@/components/navbar/NavBar'
import { store } from '@/store/store';

export function App() {
return (
<>
<Provider store={store}>
<NavBar />
<Outlet />
</Provider>
</>
);
}

Select State

@/components/navbar/NavBar.tsx

import { useSelector, useDispatch } from 'react-redux'

import { authenticate } from '@/api/authenticate'
import { authorize } from '@/api/authorize'
import type { RootState } from '@/store/store'

export function NavBar() {
const user = useSelector((state: RootState) => state.user.user);
const loading = useSelector((state: RootState) => state.user.loading);

...

return (
...
<NavigationMenuItem>
{user ? (
<span className="ml-auto">Welcome back {user.name}!</span>
) : (
<Button variant="outline"
onClick={handleSignInClick}
disabled={loading}
>
{loading ? '...' : 'Sign in'}
</Button>
)}
</NavigationMenuItem>
...

Dispatch State Updates

The handleSignInClick() dispatches a sign-in request:

export function NavBar() {
...

const dispatch = useDispatch();

async function handleSignInClick() {
// set loading to true
dispatch(authenticateAction());
// get authenticated user from API
const authenticatedUser = await authenticate();
// set authenticated user to current logged in user
dispatch(authenticatedAction(authenticatedUser));
// once the user is known set its permissions
if (authenticatedUser !== undefined) {
dispatch(authorizeAction());
const authorizedPermissions = await authorize(authenticatedUser.id);
dispatch(authorizedAction(authorizedPermissions));
}
}

...

The user name and its permissions are retrieved from a mocked API request:

@/api/authenticate.ts

export type User = {
id: string;
name: string;
};
export function authenticate(): Promise<User | undefined> {
return new Promise((resolve) => setTimeout(() => resolve({ id: '1', name: 'Admin' }), 1000));
}

@/api/authorize.ts

export function authorize(id: string): Promise<string[]> {
return new Promise((resolve) => setTimeout(() => resolve(['admin']), 1000));
}

Working with User Roles

Conditional loading of content based on user roles:

@/components/login/Vault.tsx

import { useSelector } from 'react-redux'
import { RootState } from '@/store/store'

type Content = {
children: JSX.Element
}

export function Vault({ children }: Content) {
// get array of permissions for current user
const permissions = useSelector((state: RootState) => state.user.permissions);
// if permissions exist and include 'admin' load children
if (permissions && permissions.includes('admin')) {
return <div>{ children }</div>;
}
return <h3 className="mt-8 text-center">Please sign in to view this content!</h3>;
}

Wrap all sensitive components inside a Vault instance:

import { Vault } from '@/components/login/Vault'

export function PageComponent() {
return (
<Vault>
<div className="App">
...
</div>
</Vault>
);
}

RESTful APIs

Data for a JSON mock API:

api.json

{
"events": [
{
"uuid": "90080d52-f2ac-46e1-ad1e-ec7d6ab304a1",
"title": "#6534 Entrance Left",
"description": "2023-12-26 15:51:32: [Alarm]: Motion in area [1]"
},
{
"uuid": "c8ada1fe-318b-43a4-adc5-367d526cc5dd",
"title": "#6533 Lobby",
"description": "2023-12-26 15:50:38: [Event]: admin has logged in from 192.168.2.112"
},
{
"uuid": "762ad93a-4bcc-4f8d-97d1-93d4f1da1f35",
"title": "#6456 Elevator",
"description": "2023-12-26 15:44:19: [Event]: Switching to night mode"
}
]
}

Requirements:

  • npm i -D json-server
  • npm start script in package.json
{
...,
"scripts": {
...,
"api": "json-server --watch api.json --port 8080 --delay 2500"
},
...
}

Execute the script and verify that the API is accessible:

React Typescript

Vite.js Environment Variables: To prevent accidentally leaking env variables to the client, only variables prefixed with VITE_ are exposed to your Vite-processed code. e.g.:

.env

VITE_API_URL = http://localhost:8080/events/

The URL is now exposed:

console.log(import.meta.env.VITE_API_URL)

To get TypeScript IntelliSense for user-defined env variables you can create an env.d.ts in src directory, then augment ImportMetaEnv like this:

@/env.d.ts

/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_API_URL: string
// more .env variables...
}

interface ImportMeta {
readonly env: ImportMetaEnv
}

React Typescript

Effect Fetching

Get JSON data from your API:

@/components/reports/GetReports.ts

export type ReportData = {
uuid: number;
title: string;
description: string;
}

export async function getReports() {
const response = await fetch(
// add '!' to assert that expression can’t be null or undefined
import.meta.env.VITE_API_URL!
);
const body = (await response.json()) as unknown
// type assertion for API response
assertIsReports(body);
return body;
}


export function assertIsReports(
reportData: unknown
): asserts reportData is ReportData[] {
if (!Array.isArray(reportData)) {
throw new Error("ERROR :: Report isn't an array");
}
if (reportData.length === 0) {
return
}
reportData.forEach((report) => {
if (!('uuid' in report)) {
throw new Error("ERROR :: Report doesn't contain an UUID");
}
if (typeof report.uuid !== 'string') {
throw new Error('ERROR :: UUID is not a string');
}
if (!('title' in report)) {
throw new Error("ERROR :: Report doesn't contain title");
}
if (typeof report.title !== 'string') {
throw new Error('ERROR :: Title is not a string');
}
if (!('description' in report)) {
throw new Error("ERROR :: Report doesn't contain description");
}
if (typeof report.description != 'string') {
throw new Error('ERROR :: Description is not a string');
}
})
}

The fetched JSON data can now be displayed in the report list component:

@/components/reports/ReportList.tsx

import {
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"

import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"

import { ReportData } from './GetReports'

type Props = {
reports: ReportData[];
}

export function ReportList({ reports }: Props) {
return (
<Card className="w-3/4">
<CardHeader>
<CardTitle>Surveillance Log</CardTitle>
<CardDescription>Recorded camera events</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableCaption>Latest Events</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Camera</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map((report) => (
<TableRow key={report.uuid}>
<TableCell className="font-bold text-left">{report.title}</TableCell>
<TableCell className="text-left">{report.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}

The list view can now be used in any page:

import { useEffect, useState } from 'react';

import { getReports, ReportData } from '@/components/reports/GetReports'
import { ReportList } from '@/components/reports/ReportList'

export function ReportPage() {

const [isLoading, setIsLoading] = useState(true)
const [reports, setReports] = useState<ReportData[]>([])

useEffect(() => {
let cancel = false
// fetch data from api
getReports().then((data) => {
if (!cancel) {
setReports(data);
setIsLoading(false);
}
})
// cancel load if component is unmounted
return () => {
cancel = true;
}
}, []);

if (isLoading) {
return (
<div className="w-96 mx-auto mt-6">
Loading ...
</div>
);
}

return (
...
<ReportList reports={reports} />
...
);
}

Effect Posting

@/components/reports/NewReport.ts

export type NewReportData = {
title: string;
description: string;
}


export type SavedReportData = {
uuid: string;
}

export async function savePost( newReportData: NewReportData ) {
const response = await fetch(
import.meta.env.VITE_API_URL!,
{
method: 'POST',
body: JSON.stringify(newReportData),
headers: {'Content-Type': 'application/json'}
}
)
const body = (await response.json()) as unknown;
assertIsSavedReport(body);

return { ...newReportData, ...body };
}

function assertIsSavedReport( post: any ):
asserts post is SavedReportData {
if (!('uuid' in post)) {
throw new Error("ERROR :: Post doesn't contain an uuid")
}
if (typeof post.uuid !== 'number') {
throw new Error('ERROR :: uuid is not a number');
}
}

React Router Data Loading

Use React Router to get the data before it renders a component defined on the route. This can be done by adding the getReports function as a loader:

import {
createBrowserRouter,
RouterProvider,
Navigate,
defer
} from 'react-router-dom';

import { CameraPage } from '@/pages/CameraPage'
import { getReports } from '@/components/reports/GetReports'

const router = createBrowserRouter(
[{
...
{
path: 'camera/:id',
element: <CameraPage />,
loader: async () => defer({ reports: getReports() })
},
...

The data is now fetched when the route is triggered and made available to our component via a useLoaderData hook::

@/pages/CameraPage.tsx

import { Suspense } from 'react'

import {
useParams,
useLoaderData,
Await,
Link
} from 'react-router-dom'

import { ReportList } from '@/components/reports/ReportList'
import { assertIsReports } from '@/components/reports/GetReports'
import { ReportData } from '@/components/reports/types'

export function CameraPage() {

...

// get camera reports from react-router route
const data = useLoaderData()
assertIsData(data)

return (
...

<CardContent>
<Suspense fallback={<div>Fetching...</div>}>
<Await resolve={data.reports}>
{(reports) => {
assertIsReports(reports)
return <ReportList reports={reports} />
}}
</Await>
</Suspense>
</CardContent>

...
)}

type Data = {
reports: ReportData[];
}

export function assertIsData(data: unknown): asserts data is Data {
if (typeof data !== 'object') {
throw new Error("ERROR :: Sent report data isn't an object");
}
if (data === null) {
throw new Error('ERROR :: Report data is null');
}
if (!('reports' in data)) {
throw new Error("ERROR :: data doesn't contain reports");
}
}

Video Embedding

hls Streams

npm install react-hls-player

@/components/video_streamer/HLSPlayer.tsx

import ReactHlsPlayer from 'react-hls-player'

export function HLSPlayer( href: { url: string } ) {

return (
<>
<ReactHlsPlayer
src={href.url.toString()}
autoPlay={false}
controls={true}
width="100%"
height="auto"
hlsConfig={{
maxLoadingDelay: 4,
minAutoBitrate: 0,
lowLatencyMode: true,
}}
/>
</>
);
}

@/components/video_streamer/VideoWall.tsx

import { HLSPlayer } from '@/components/video_player/HLSPlayer'

const href: URL = new URL("https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8")

export function VideoWall() {
return (
<Card className="mt-12">
<CardHeader>
<CardTitle>Camera Live Video</CardTitle>
<CardDescription>Embedded HLS stream unsing hls.js</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-flow-row-dense grid-cols-3 grid-rows-3 gap-2">
<div className="col-span-2 row-span-2">
<HLSPlayer url={href} />
</div>
...

Video File Playback

npm install video.js
npm install -D @types/video.js

@/components/video_player/VideoPlayer

import { useRef, useEffect } from 'react'
import videojs from 'video.js'
import 'video.js/dist/video-js.css'

export const VideoPlayer = (props) => {
const videoRef = useRef(null)
const playerRef = useRef(null)

const {options, onReady} = props;

useEffect(() => {

// Make sure Video.js player is only initialized once
if (!playerRef.current) {
// The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
const videoElement = document.createElement("video-js");

videoElement.classList.add('vjs-big-play-centered');
videoRef.current.appendChild(videoElement);

const player = playerRef.current = videojs(videoElement, options, () => {
videojs.log('player is ready');
onReady && onReady(player);
});

// You could update an existing player in the `else` block here
// on prop change, for example:
} else {
const player = playerRef.current;

player.autoplay(options.autoplay);
player.src(options.sources);
}
}, [options, videoRef]);

// Dispose the Video.js player when the functional component unmounts
useEffect(() => {
const player = playerRef.current;

return () => {
if (player && !player.isDisposed()) {
player.dispose();
playerRef.current = null;
}
};
}, [playerRef]);

return (
<div data-vjs-player>
<div ref={videoRef} />
</div>
);
}

@/components/video_player/VideoWall

import { useRef } from 'react'
import videojs from 'video.js'

import { VideoPlayer } from '@/components/video_player/VideoPlayer'

const videoJsOptions = {
autoplay: false,
controls: true,
responsive: true,
fluid: true,
sources: [
{
src: "//vjs.zencdn.net/v/oceans.mp4",
type: "video/mp4"
}
]
};

export function VideoWall(id: {camera: string}) {

const playerRef = useRef(null)

const handlePlayerReady = (player) => {
playerRef.current = player

// You can handle player events here, for example:
player.on('waiting', () => {
videojs.log('player is waiting')
})

player.on('dispose', () => {
videojs.log('player will dispose')
})
}

return (
<div className="col-span-2 row-span-2">
<VideoPlayer options={videoJsOptions} onReady={handlePlayerReady} />
</div>
);
}