Background
"Tour of Heroes" is the official tutorial app of Angular 2+. The app contains some functional features and technical features which are common when building a real-world business application:
- A few screens presenting tables and nested data
- Data binding
- Navigation
- CRUD operations over a backend, and optionally through a generated client API
- Unit testing and integration testing
In this series of articles, I will demonstrate the programmer experiences of various frontend development platforms when building the same functional features: "Tour of Heroes", a fat client talking to a backend.
The frontend apps on Angular, Aurelia, React, Vue, Xamarin and MAUI are talking to the same ASP.NET (Core) backend through generated client APIs. To find the other articles in the same series, please search "Heroes" in my articles. And at the end of the series, some technical factors of programmer experiences will be discussed:
- Computing science
- Software engineering
- Learning curve
- Build size
- Runtime performance
- Debugging
Choosing a development platform involve a lot of non-technical factors which won't be discussed in this series.
References:
Introduction
This article is focused on React TS.
Development platforms
- Web API on ASP.NET Core 8
- Frontend on React 18.2.0
Demo Repository
Checkout DemoCoreWeb in GitHub, and focus on the following areas:
ASP.NET Core Web API.
This is a rewrite of the official tutorial demo of Angular "Tour of Heroes", for side by side comparison based on the same functional features.
Remarks
DemoCoreWeb was established for testing NuGet packages of WebApiClientGen and demonstrating how to use the libraries in real world projects.
Using the Code
Prerequisites
- Core3WebApi.csproj has NuGet packages "Fonlow.WebApiClientGenCore" and "Fonlow.WebApiClientGenCore.Axios" imported.
- Add CodeGenController.cs to Core3WebApi.csproj.
- Core3WebApi.csproj has CodeGen.json . This is optional, just for the convenience of running some PowerShell script to generate client APIs.
- CreateWebApiClientApi3.ps1. This is optional. This script will launch the Web API on DotNet Kestrel web server and post the data in CodeGen.json.
Remarks
Depending on your CI/CD process, you may adjust item 3 and 4 above. For more details, please check:
Also, if your React TS project uses Fetch API, you may import WebApiClientGen plugin Fonlow.WebApiClientGenCore.Fetch.
Generate Client API
Not like Angular and Aurelia which are TS/JS framework, React is a JS library which does not include built-in HTTP client. And React programmers typically would use Fetch API or AXIOS. In React Heroes, AXIOS is used.
In CodeGen.json, include the following:
"Plugins": [
{
"AssemblyName": "Fonlow.WebApiClientGenCore.Axios",
"TargetDir": "..\\..\\..\\..\\ReactHeroes\\src\\clientapi",
"TSFile": "WebApiCoreAxiosClientAuto.ts",
"AsModule": true,
"ContentType": "application/json;charset=UTF-8"
},
Run "CreateWebApiClientApi3.ps1", the generated codes will be written to "WebApiCoreAxiosClientAuto.ts".
Data Models and API Functions
export namespace DemoWebApi_Controllers_Client {
export interface Hero {
id?: number | null;
name?: string | null;
}
}
export class Heroes {
constructor(private baseUri: string = window.location.protocol +
'//' + window.location.hostname + (window.location.port ? ':' +
window.location.port : '') + '/') {
}
delete(id: number | null, headersHandler?: () =>
{[header: string]: string}): Promise<AxiosResponse> {
return Axios.delete(this.baseUri + 'api/Heroes/' + id,
{ headers: headersHandler ? headersHandler() : undefined });
}
getHero(id: number | null, headersHandler?: () =>
{[header: string]: string}): Promise<DemoWebApi_Controllers_Client.Hero> {
return Axios.get<DemoWebApi_Controllers_Client.Hero>
(this.baseUri + 'api/Heroes/' + id, { headers: headersHandler ?
headersHandler() : undefined }).then(d => d.data);
}
getHeros(headersHandler?: () => {[header: string]: string}):
Promise<Array<DemoWebApi_Controllers_Client.Hero>> {
return Axios.get<Array<DemoWebApi_Controllers_Client.Hero>>
(this.baseUri + 'api/Heroes', { headers: headersHandler ?
headersHandler() : undefined }).then(d => d.data);
}
post(name: string | null, headersHandler?: () =>
{[header: string]: string}): Promise<DemoWebApi_Controllers_Client.Hero> {
return Axios.post<DemoWebApi_Controllers_Client.Hero>
(this.baseUri + 'api/Heroes', JSON.stringify(name),
{ headers: headersHandler ? Object.assign(headersHandler(),
{ 'Content-Type': 'application/json;charset=UTF-8' }):
{ 'Content-Type': 'application/json;charset=UTF-8' } }).then(d => d.data);
}
put(hero: DemoWebApi_Controllers_Client.Hero | null,
headersHandler?: () => {[header: string]: string}):
Promise<DemoWebApi_Controllers_Client.Hero> {
return Axios.put<DemoWebApi_Controllers_Client.Hero>
(this.baseUri + 'api/Heroes', JSON.stringify(hero),
{ headers: headersHandler ? Object.assign(headersHandler(),
{ 'Content-Type': 'application/json;charset=UTF-8' }):
{ 'Content-Type': 'application/json;charset=UTF-8' } }).then(d => d.data);
}
}
While there could be multiple ways of utilizing the generated API functions, the official React tutorial or documentation does not seem to suggest, recommend or mandate a predefined way of utilizing HTTP client calls. I am not sure what could be the orthodox way or the popular way by seasoned React programmers when crafting complex business applications. Here's what I would do:
import { DemoWebApi_Controllers_Client } from './clientapi/WebApiCoreAxiosClientAuto';
export let HeroesApi = heroesApi();
function heroesApi() {
const apiBaseUri = 'http://localhost:5000/';
const service = new DemoWebApi_Controllers_Client.Heroes(apiBaseUri);
return service;
}
Remarks
- If you are a seasoned React programmer, please advise a better, orthodox or popular way of utilizing generated client API. Your advice will be greatly appreciated.
Views
Editing
HeroDetail.tsx
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import './HeroDetail.css';
import { DemoWebApi_Controllers_Client } from './clientapi/WebApiCoreAxiosClientAuto';
import { HeroesApi } from './HeroesApi';
export default function HeroDetail()
{
const service = HeroesApi
const { id } = useParams();
const [hero, setHero] = useState<DemoWebApi_Controllers_Client.Hero | undefined>(undefined);
const heroId: any = id;
const navigate = useNavigate();
const nameInput = useRef<HTMLInputElement>(null);
useEffect(() => {
console.debug('getHero...');
service.getHero(heroId).then(
h => {
if (h) {
setHero(h);
}
}
).catch(error => alert(error));
}, []);
if (!hero) {
return <div>AAA</div>;
}
function save(): void {
service.put(hero!).then(
d => {
setHero({...hero})
console.debug('response: ' + JSON.stringify(d));
}
).catch(error => alert(error));
}
function goBack(): void {
navigate(-1);
}
function handleChange(e: React.FormEvent<HTMLInputElement>) {
hero!.name = e.currentTarget.value;
setHero({...hero});
}
return (
<div className="hero-detail">
<h2>{hero.name} Details</h2>
<div><span>id: </span>{hero!.id}</div>
<div>
<label htmlFor="hero-name">Hero name: </label>
<input id="hero-name" value={hero.name!}
placeholder="Name" onChange={handleChange} ref={nameInput} />
</div>
<button type="button" onClick={goBack}>go back</button>
<button type="button" onClick={save}>save</button>
</div>
);
}
Heroes List
Heroes.tsx
import { useEffect, useRef, useState } from 'react';
import './Heroes.css';
import { DemoWebApi_Controllers_Client } from './clientapi/WebApiCoreAxiosClientAuto';
import { Link, useNavigate } from 'react-router-dom';
import {HeroesApi} from './HeroesApi';
export default function Heroes() {
const service = HeroesApi;
const [heroes, setHeroes] = useState<DemoWebApi_Controllers_Client.Hero[]>([]);
const [selectedHero, setSelectedHero] =
useState<DemoWebApi_Controllers_Client.Hero | undefined>(undefined);
const navigate = useNavigate();
const heroNameElement = useRef<HTMLInputElement>(null);
useEffect(() => {
console.debug('getHeros...');
service.getHeros().then(
data => {
setHeroes(data);
}
).catch(error => console.error(error))
}, []);
return (
<>
<h2>My Heroes</h2>
<div>
<label htmlFor="new-hero">Hero name: </label>
<input id="new-hero" ref={heroNameElement} />
<button type="button" className="add-button" onClick={addAndClear}>
Add hero
</button>
</div >
<ul className="heroes">
{
heroes.map((h) =>
<li>
<Link to={`/detail/${h.id}`} key={h.id}>
<span className="badge">{h.id}</span> {h.name}
</Link>
<button type="button" className="delete" title="delete hero"
onClick={() => deleteHero(h)}>x</button>
</li >)
}
</ul >
</>
);
function onSelect(hero: DemoWebApi_Controllers_Client.Hero): void {
setSelectedHero(hero);
}
function gotoDetail(): void {
navigate(`/detail/${selectedHero?.id}`);
}
function addAndClear() {
add(heroNameElement.current?.value);
heroNameElement.current!.value = '';
}
function deleteHero(hero: DemoWebApi_Controllers_Client.Hero):
void {
service.delete(hero.id!).then(
() => {
setHeroes(heroes?.filter(h => h !== hero));
if (selectedHero === hero) { setSelectedHero(undefined); }
});
}
function add(name: string | undefined): void {
if (!name) { return; }
name = name.trim();
service.post(name).then(
hero => {
setHeroes([...heroes, hero]);
console.debug('hero added: ' + heroes.length);
setSelectedHero(undefined);
});
}
}
View Models
HeroDetail.tsx has implemented two-way binding for the input element through some lengthy app codes, however, there's no automatic update to:
<h2>{hero.name} Details</h2>
after hero.name
is altered. More lengthy app codes are expected to update. This is because React is a JS library, not a JS framework like Aurelia which provides built-in MVVM architecture, while two-way binding is a basic feature of any MVVM architecture. Proficient React programmers have been using wide variety of ways for MVVM.
Routing
React provides built-in routing mechanism which is abstract enough.
Symbolic routing globally or within a module
function AppRouteMap() {
return useRoutes([
{ path: 'demo', element: <Demo /> },
{ path: '/', element: <Home /> },
{
element: <Home />,
children: [
{ path: 'dashboard', element: <Dashboard /> },
{ path: 'heroes', element: <Heroes /> },
{ path: 'detail/:id', element: <HeroDetail /> }
]
}
]);
}
export default function App() {
return (
<BrowserRouter>
<AppRouteMap />
</BrowserRouter>
);
}
<Link to={`/detail/${h.id}`} key={h.id}>
<span className="badge">{h.id}</span> {h.name}
</Link>
Integration Testing
Since the frontend of "Tour of Heroes" is a fat client, significant portion of the integration testing is against the backend. Two examples:
Points of Interests
DemoCoreWeb in GitHub is initially created for testing released packages of WebApiClient
, it also serves well the following purposes:
- A demo not too simple and not too complex for various development platforms. After you learn one, it should be easy for you to learn the others, based on the same business functionality.
- Demonstrating programmer experience of using various development platforms, to give you some ideas when choosing a development platform for your next project, based on your business content and context.
The backend provides multiple sets of Web API, while "Tour of Heroes" consumes only those exposed in HeroesController
. In the real world, a backend may serve multiple frontend apps, while a front end app may talk to multiple backends.
Even though I had written this React Heroes app, I have never used React for commercial projects, thus haven't deep-dive enough to really understand React and its technical landscape. There may be some misconceptions about React in this article. Please leave your comment if you are a seasoned React programmer, and I would greatly appreciate it.
History
- 20th November, 2023: Initial version