Formation
React

Created by
KNP Labs
Date
2025
Link
Table of contents
01
Getting started
Bootstrap a react application
04
State & Lifecycle
In legacy React, class components use state and lifecycle methods to manage data and handle updates.
02
JSX
The react templating language
05

Hooks
Class components help understand React's state, props, and lifecycle. Hooks offer a modern way to manage them.
Bootstrap a react application
Bootstrap a react application
TypeScript
Adding types validation within your JavaScript code
06
03
Components
Components let you split the UI into independent, reusable pieces, and think about each piece in isolation.
Table of contents

Side Effects
Handle side effects with the redux Thunk middleware
10
Unit testing
Test reducers and components with Vitest and @testing-library
09
08
Redux
Centralizing your application's state and logic enables powerful capabilities like undo/redo, state persistence, and much more.
Forms
Handle forms, validation and data submission to an API
11
07
Routing
How to handle routing in React Application with React Router
01
Getting started
Bootstrap a React application


Start a New React Project
Using Vite

Getting started
-
A modern build tool for web projects like React application
-
Designed for fast development and optimal performance
-
As of February 14, 2025, React has deprecated Create React App for new SPA apps and recommends migrating to modern tools like Vite, Parcel, or RSBuild.
Using a React framework
-
Since the new docs, React recommend picking one of the React-powered frameworks.
Getting started
Create new app using Vite


Prerequisites
-
Vite requires Node.js version 18+ or 20+ https://nodejs.org/en/download/package-manager/
-
Package manager (npm, yarn, pnpm)
Bootstrap
pnpm create vite git-clicker --template react
cd git-clicker
pnpm install
This will create a git-clicker
folder with vite + react app inside
nvm use 22
corepack enable pnpm
Getting started
Architecture

git-clicker/
├── node_modules
├── public
│ └── vite.svg
├── src
│ ├── assets
│ │ └── react.svg
│ ├── App.css
│ ├── App.jsx
│ ├── index.css
│ └── main.jsx
├── .gitignore
├── eslint.config.js
├── index.html
├── package.json
├── pnpm-lock.yaml
├── README.md
└── vite.config.js

Getting started
Run the application
pnpm dev
This is a development server
A hot module reload is configured


Getting started
Clean up
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root"));
root.render(
<div/>
);

Rewrite src/main.jsx
Only keep these files:
.
├── node_modules
├── public
│ └── vite.svg
├── src
│ └── main.jsx
├── .gitignore
├── index.html
├── package.json
├── pnpm-lock.yaml
└── vite.config.js
Thinking about init Git repository

Getting started
Clean up
pnpm rm eslint @eslint/js eslint-plugin-react-hooks eslint-plugin-react-refresh globals

Remove unnecessary dependencies

02
JSX
The templating language of React.


JSX
Hello world
JSX looks like HTML, but don’t be fooled—it’s JavaScript in disguise!
It’s called JSX—a syntax extension for JavaScript. While it may look like a templating language, JSX has the full power of JavaScript.
JSX produces React elements.

🔹 Before React: Manual DOM manipulation
const h1 = document.createElement('h1');
h1.textContent = 'Hello, world!';
document.getElementById('root').appendChild(h1);
🔹 With React (createElement
): Less manual, but verbose
const element = <h1>Hello, world!</h1>;
ReactDOM.createRoot(document.getElementById('root')).render(element);
const element = React.createElement('h1', null, 'Hello, world!');
ReactDOM.createRoot(document.getElementById('root')).render(element);
🔹 With JSX: Cleaner, more readable 🚀
JSX
Hello world
You can edit the main.jsx file
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root"));
const element = <h1>Hello World</h1>;
root.render(element);

JSX
Embedded expressions in JSX
const name = 'John Doe'
const element = <h1>Hello, {name}</h1>
ReactDOM.createRoot(
document.getElementById('root')
).render(element)
Works with scalar values :
function capitalize(name) {
return name.toUpperCase()
}
const name = 'John Doe'
const element = <h1>Hello, {capitalize(name)}</h1>
Also with more complex JavaScript expression as well as function :

JSX
Attributes
You can also set attributes on elements
const element = <button role="submit">Submit</button>

const element = <img src={user.avatarUrl} />
function getAvatarUrl(user) {
return `https://media.mywebsite.com/avatar/${user.id}`
}
const element = <img src={getAvatarUrl(user)} />
Raw
From JavaScript expressions
JSX
Children
React elements can also have children
const element = (
<div>
<h1>Hello {user.name}</h1>
<h2>Good to see you!</h2>
</div>
)

const greeting = (
<div>
<h1>Hello {user.name}</h1>
</div>
)
const header = (
<header>
<img src="https://media.com/logo" />
{greeting}
</header>
)
03
Components
Components let you split the UI into independent, reusable pieces, and think about each piece in isolation.


Components

Components let you split the UI into independent and reusable pieces.
Traditionally when creating web pages, web developers marked up their content and then added interaction using some JavaScript.
React puts interactivity first while still using the same logic :
Thus, a React component is a JavaScript function where you can use the JSX markup language.
The argument of this function is a "Props" object, each properties of this object are data that you can pass to your component.
function Greeting(props) {
return (
<div>
<h1>Hello {props.name}</h1>
<h2>Good to see you!</h2>
</div>
)
}
const element = (
<div>
<Greeting name="John Doe" />
<p>Lorem ipsum...</p>
</div>
)
Usage:
Example:
Components
Functional vs Class components

Each component is a self-contained piece of the UI, making your code more modular and maintainable.
function Greeting(props) {
return (
<div>
<h1>Hello {props.name}</h1>
<h2>Good to see you!</h2>
</div>
)
}
class Greeting extends React.Component {
render() {
return (
<div>
<h1>Hello {this.props.name}</h1>
<h2>Good to see you!</h2>
</div>
)
}
}
Class Components (Legacy)
Before React 16.8, components were written using ES6 classes.
Class components are now considered legacy since React introduced Hooks.
Functional Components (Modern)
We can write components as simple functions:
Functional components are now the standard, they are easier to read and write, more performant, compatible with Hooks
Practical Work

Components
Create a Greeting component
The <Greeting /> components asks for 2 props: firstName and lastName.
The component should be written using a class.
Use this Greeting component in the root element
Use this <Greeting /> component to great the following users:
- John Doe
- Georges Abitbol
- Edgar KNP

Practical Work

class Foo extends React.Component {
render() {
return (
<div>
<h1>Display something {this.props.bar}</h1>
</div>
)
}
}
Components

Practical Work - Correction
import React from "react";
class Greeting extends React.Component {
render() {
return (
<div>
<h1>Hello {this.props.firstName} {this.props.lastName}</h1>
<h2>Good to see you!</h2>
</div>
)
}
}
export default Greeting
import Greeting from './Greeting'
const greetings = (
<div>
<Greeting firstName="John" lastName="Doe" />
<Greeting firstName="Georges" lastName="Abitbol" />
<Greeting firstName="Edgar" lastName="KNP" />
</div>
);
Components
Components props are Read-Only

You cannot modify props inside the component itself!
import React from "react";
class Greeting extends React.Component {
render() {
this.props.name = 'Another name'
return (
<h1>Hello {this.props.name}</h1>
)
}
}
const element = (
<div>
<Greeting name="John Doe" />
</div>
)
<= This is not allowed

Components
The children prop

Components can have other components as children.
import React from "react";
class CustomButton extends React.Component {
render() {
return (
<button style={{ backgroundColor: this.props.color }}>
{this.props.children}
</button>
)
}
}
const customButton= (
<CustomButton color="red">
<span>Click me</span>
</CustomButton>
)
Components
Props can be a function

import React from "react";
class Button extends React.Component {
render() {
return (
<button onClick={this.props.onClick} type="button">
{this.props.children}
</button>
);
}
}
function handleClick() {
alert("Congratulation! You clicked a button!")
}
const button = (
<Button onClick={handleClick}>Click me</Button>
)
Components
Css and class names

import React from 'react'
import './Button.css'
class Button extends React.Component {
render() {
return (
<button className="btn">
{this.props.children}
</button>
)
}
}
/* Button.css */
.btn {
background-color: #38c78b;
border: 1px solid #1f6e4d;
}

class is a javascript reserved keyword. Thus react provides a special className prop in order to gives elements some css classes.
Components
Iterate through a list to create components


The key prop on react elements is mandatory. It is used by React to optimize the rendering of components.
Using array indices as keys is generally not recommended, React specifically warns against this practice in its documentation
import React from 'react';
export class PersonList extends React.Component {
render() {
const persons = [
{ name: "John Doe" },
{ name: "Georges Abitbol" },
{ name: "Kevin McGregor" },
];
return (
<ul>
{persons.map((person, index) => (
<li key={index}>{person.name}</li>
))}
</ul>
);
}
}
Components
Conditional rendering

import React from "react";
/**
* props:
* name: string
* isMorning: boolean
*/
class GreetingTime extends React.Component {
render() {
const { name, isMorning } = this.props;
return (
<div>
{isMorning ? <h1>Good Morning {name}</h1> : <h2>Goodbye {name}</h2>}
</div>
);
}
}
export default GreetingMoment;
import React from "react";
/**
* props:
* name: string
* withImage: boolean
*/
class GreetingImage extends React.Component {
render() {
const { name, withImage } = this.props;
return (
<div>
<h1>Good Morning {name}</h1>
{withImage && (
<img src="https://api.dicebear.com/9.x/adventurer/svg" alt="avatar" />
)}
</div>
);
}
}
04
State and Lifecycles
In legacy React, state and lifecycle methods allow class components to manage internal data and respond to component changes over time.


State and Lifecycles

A component state is a javascript object containing data or informations about the component.
import React from "react";
class Checkbox extends React.Component {
constructor(props) {
super(props);
this.state = {
check: false,
};
}
render() {
const { name, label } = this.props;
return (
<div>
<input
name={name}
type="checkbox"
checked={this.state.check}
/>
<label htmlFor={name}>{label}</label>
</div>
);
}
}
The state can change over time. Whenever it changes, the component re-renders.
The change in state can happen as a response to user action or system-generated events and these changes determine the behavior of the component and how it will render.

The state cannot be updated directly. You have to use the setState method which is defined by React.
class Checkbox extends React.Component {
constructor(props) {
super(props)
this.state = {
checked: false,
};
}
handleCheck = () => {
this.setState({
checked: !this.state.checked
})
}
render() {
const { name, label } = this.props
return (
<div>
<input
name={name}
type="checkbox"
checked={this.state.checked}
onClick={this.handleCheck}
/>
<label htmlFor={name}>{label}</label>
</div>
);
}
}
This methods schedules updates to the component local state.
The update may be asynchronous. React can batch several setState calls for performance. Calling setState does not guaranty that the state is directly modified right after the call.

// Wrong
this.state.checked = true
// Correct
this.setState({ checked: true })
State and Lifecycles

There is 2 kinds of data structure in react: states and props:
- States: are controlled by the component in which they are declared
- Props: are controlled from outside the component
React performs a component re-render each time a state or a prop is modified.
A component may choose to pass its state down to its child component.
State and Lifecycles
Lifecycle


State and Lifecycles
Component Lifecycle

State and Lifecycles
class ExampleDidMount extends React.Component {
constructor(props) {
super(props);
console.log("Constructor");
}
componentDidMount() {
console.log("Component did mount!");
}
render() {
return <h1>Component Mounted</h1>;
}
}
Mounting

Used for: API calls, subscriptions, timers.
React class components go through three lifecycle phases:
- Mounting (component is created)
- Updating (component state or props change)
- Unmounting (component is removed from the DOM)
React provides lifecycle methods to execute code at each phase.
constructor()
: Initializes the component.
componentDidMount()
: Executes after the component is added to the DOM.

State and Lifecycles
class ExampleDidUpdate extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}
componentDidUpdate() {
console.log("Component did update!");
}
increment = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
return (
<div>
<h1>Count: {this.state.count}</h1>
<button onClick={this.increment} type="button">
Increment
</button>
</div>
);
}
}
Updating
componentDidUpdate()
: Runs after a state or prop update.

Used for: fetching new data when props change, DOM updates.

State and Lifecycles
class ExampleWillUnmount extends React.Component {
componentWillUnmount() {
console.log("Component will unmount!");
}
render() {
return <h1>Goodbye!</h1>;
}
}
Unmounting

Used for: cleanup (event listeners, intervals, timers, API subscriptions).
componentWillUnmount()
: Runs before the component is removed.
Practical Work

Practical Work
Create a <Game /> component
This component must hold in its state the number of line of code created.
Create a <Gitcoin /> component
Generate 1 line of code each time this coin is clicked.
GitClicker - Cookie Clicker like
Create a <Score /> component
This component must display the number of lines produced.


Generate as many line of code as you can!
Practical Work
<Game />
GitClicker - Correction
// ./src/components/Game.jsx
import React from "react";
import "./Game.css";
import { Gitcoin } from "./Gitcoin";
import { Score } from "./Score";
export class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
lines: 0,
};
}
handleClick() {
this.setState({
lines: this.state.lines + 1,
});
}
render() {
return (
<main className="game">
<Score lines={this.state.lines} />
<Gitcoin onClick={this.handleClick.bind(this)} />
</main>
);
}
}
.game {
display: flex;
flex-direction: column;
align-items: center;
width: 40%;
}
Game.css

Practical Work
<Gitcoin />
GitClicker - Correction
// ./src/components/Gitcoin.jsx
import React from "react";
import "./Gitcoin.css";
import githubIcon from "../assets/github.svg";
export class Gitcoin extends React.Component {
render() {
const { onClick } = this.props;
return (
<button className="gitcoin" onClick={onClick} type="button">
<img src={githubIcon} alt="Gitcoin" />
</button>
);
}
}
.gitcoin {
display: block;
width: 15rem;
height: 15rem;
border: none;
background: transparent;
outline: none;
cursor: pointer;
}
.gitcoin > img {
width: 100%;
}
Gitcoin.css

Practical Work
<Score />
GitClicker - Correction
// ./src/components/Score.jsx
import React from "react";
export class Score extends React.Component {
render() {
const { lines } = this.props;
return <h3>{lines} lines</h3>;
}
}

05
Hooks
Class components are a great way to learn how React states, props and lifecycle work.
React Hooks are modern functions that enable components to manage state, lifecycle methods, and other React features.


Hooks

import React from "react";
export class PlusButton extends React.Component {
constructor(props) {
super(props);
this.state = { counter: 0 };
}
increment() {
this.setState({ counter: this.state.counter + 1 });
}
render() {
return <button onClick={this.increment(0)}>+</button>;
}
}
How to deal with states?
Hooks to the rescue!
Hooks

Class components vs hooks
Hooks allow you to use state and other React features without writing a class.
Reusability: In class components, reusing stateful logic is difficult. Hooks let you extract and reuse stateful logic independently.
Readability: Large class components become hard to manage. Hooks help split logic into smaller, more focused functions.
Simplicity: Classes can be confusing. You need to understand how this
works, bind event handlers, and write more verbose code. Hooks simplify component logic.
Hooks
State

import { useState } from "react";
function Checkbox({ name, label }) {
const [checked, setChecked] = useState(false); // Default value: false
return (
<div>
<input
name={name}
type="checkbox"
checked={checked}
onChange={() => setChecked(!checked)}
/>
<label htmlFor={name}>{label}</label>
</div>
);
}
The useState
hook lets you define local state for any data type (string, number, boolean, array, object). When state updates, the component re-renders to reflect the changes.
State changes in response to user actions or system events, determining how the component renders.
A component's state is a JavaScript object storing data that influences rendering.
Hooks
State

A component may choose to pass its state down to its child component throw props.
function Checkbox({
name,
label,
checked,
onChange
}) {
return (
<div>
<input
name={name}
type="checkbox"
checked={checked}
onChange={onChange}
/>
<label htmlFor={name}>{label}</label>
</div>
);
}
import Checkbox from "./Checkbox";
function ParentComponent() {
const [hasAcceptedTerms, setHasAcceptedTerms] = useState(false);
const handleAcceptTerms = () => {
setHasAcceptedTerms(!hasAcceptedTerms);
};
return (
<Checkbox
name="acceptedTerms"
label="This is a checkbox"
onChange={handleAcceptTerms}
checked={hasAcceptedTerms}
/>
);
}
Practical Work

Practical Work
Refactor your React classes to functional components

import React, { useState } from 'react';
function Example() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
Usage of useState
:
Practical Work
<Game />
GitClicker - Correction
// ./src/components/Game.jsx
import { useState } from "react";
import "./Game.css";
import { Gitcoin } from "./Gitcoin";
import { Score } from "./Score";
export function Game() {
const [lines, setLines] = useState(0);
const handleClick = () => {
setLines(lines + 1);
};
return (
<main className="game">
<Score lines={lines} />
<Gitcoin onClick={handleClick} />
</main>
);
}
.game {
display: flex;
flex-direction: column;
align-items: center;
width: 40%;
}
Game.css

Practical Work
<Gitcoin />
GitClicker - Correction
// ./src/components/Gitcoin.jsx
import "./Gitcoin.css";
import githubIcon from "../assets/github.svg";
export function Gitcoin({ onClick }) {
return (
<button className="gitcoin" onClick={onClick} type="button">
<img src={githubIcon} alt="Gitcoin" />
</button>
);
}
.gitcoin {
display: block;
width: 15rem;
height: 15rem;
border: none;
background: transparent;
outline: none;
cursor: pointer;
}
.gitcoin > img {
width: 100%;
}
Gitcoin.css


You can use JavaScript deconstruction in order to have a direct access on each props (before : props.onClick
)
Practical Work
vite.config.js
GitClicker - Correction
import path from "node:path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
Creates aliases to according folders
This allows you to load components from an absolute path instead of a relative path.
import "./Gitcoin.css";
import githubIcon from "@/assets/github.svg";
// instead of
// import githubIcon from '../assets/github.svg'
export function Gitcoin({ onClick }) {
return (
<button className="gitcoin" onClick={onClick} type="button">
<img src={githubIcon} alt="Gitcoin" />
</button>
);
}

Practical Work
<Score />
GitClicker - Correction
// ./src/components/Score.jsx
export function Score({ lines }) {
return <h3>{lines} lines</h3>;
}

Bonus :)

Bonus :)
Lets add an animation on the Gitcoin!

.gitcoin {
display: block;
width: 15rem;
height: 15rem;
border: none;
background: transparent;
outline: none;
cursor: pointer;
& > img {
width: 100%;
border-radius: 100%;
animation: bounce-up 0.2s;
}
&:active > img {
animation: bounce-down 0.2s;
}
}
@keyframes bounce-up {
0% {
transform: scale(0.9)
}
100% {
transform: scale(1)
}
}
@keyframes bounce-down {
0% {
transform: scale(1)
}
100% {
transform: scale(0.9)
}
}
Add a pure css bouncing animation to the gitcoin when it's clicked.

Practical Work

Practical Work
Create a store of items

Create a items.json
file
An item has a name, price and a linesPerMilliSecond indicator.
[
{ "name": "Bash", "price": 10, "linesPerMillisecond": 0.1 },
{ "name": "Git", "price": 20, "linesPerMillisecond": 1.2 },
{ "name": "Javascript", "price": 50, "linesPerMillisecond": 14.0 },
{ "name": "React", "price": 75, "linesPerMillisecond": 75.0 },
{ "name": "Vim", "price": 100, "linesPerMillisecond": 100.0 }
]
You will soon be able to buy items to boost your productivity!

Create a <Store />
component
This component will display the list of all items present in the items.json
file.

The buy button must remain disabled if you do not have enough lines!
Practical Work
Correction <Store />

// ./src/components/Store.jsx
import "./Store.css";
import items from "@/items.json";
export function Store({ lines }) {
const canBuy = (item) => {
return lines >= item.price;
};
return (
<ul>
{items.map((item) => (
<li key={item.name} className="item">
<span>
{item.name} - {item.price}
</span>
<button disabled={!canBuy(item)} type="button">
Buy
</button>
</li>
))}
</ul>
);
}
Store.css
.item {
display: flex;
width: 15rem;
flex-direction: row;
justify-content: space-between;
}
.item > span,
.item > button {
margin-bottom: 0.5rem;
}
The string attribute key
is a special attribute you need to include when rendering an array of elements by mapping , array.map()
, onto itself. Keys help React identify which items have changed, are added, or are removed. Keys should be given to the elements inside the array to give the elements a stable identity.

Practical Work
Correction <Game />

// ./src/components/Game.js
import { useState } from "react";
import { Gitcoin } from "./Gitcoin";
import { Score } from "./Score";
import { Store } from "./Store";
import "./Game.css";
export function Game() {
const [lines, setLines] = useState(0);
const handleClick = () => {
setLines(lines + 1);
};
return (
<main className="game">
<Score lines={lines} />
<Gitcoin onClick={handleClick} />
<Store lines={lines} />
</main>
);
}
Hooks
useEffect

The useEffect
Hook lets you perform side effects in functional components.
In React, side-effects are operations that occur outside of the typical component lifecycle
Examples :
- data fetching
- accessing local or sessions storage
- subscriptions to external services
- Setting up timers or intervals to execute code at specific intervals
Hooks
useEffect

import { useState, useEffect } from "react";
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
// Runs after every render/re-render due to state or prop changes
alert(`You clicked ${count} times`);
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
The callback inside useEffect
runs after each render.
Re-renders occur whenever state or props change.

Updating state inside useEffect
(e.g., calling setCount()
) without a proper dependency array can cause an infinite loop:
setCount > re-render > useEffect > setCount > re-render > ...
The useEffect
hooks accept a callback as first parameter
Hooks
useEffect

import { useState, useEffect } from 'react'
function Example() {
const [mainCount, setMainCount] = useState(0)
const [asideCount, setAsideCount] = useState(0)
useEffect(() => {
alert(`You clicked ${mainCount} times`)
}, [mainCount]);
return (
<div>
<p>You clicked {mainCount} times</p>
<button onClick={() => setMainCount(mainCount + 1)}>
Click me
</button>
<p>You clicked {asideCount} times on aside count</p>
<button onClick={() => setAsideCount(asideCount + 1)}>
Aside count increment
</button>
</div>
);
}
Now the useEffect callback will only be executed if the mainCount
state has changed

The Effect Hook accepts a 2nd parameter.
Hooks
useEffect

useEffect(() => {
// Runs after every render
})
3 different behaviors
Executed on each re-render without exception
useEffect(() => {
// Runs only when `count` changes
}, [count]);
Executed on each re-render where count
has changed
count
could be a prop, state, function or even a simple variable.
Not recommended.
useEffect(() => {
// Runs only once on mount
}, []);
Executed only once during the first render
Hooks
useEffect

useEffect(() => {
const timer = setInterval(() => console.log("Hello world!"), 1000);
return () => {
clearInterval(timer);
};
}, []);
You can specified a clean up function to your effect by returning a callback.
You can specify a cleanup function by returning a callback inside useEffect
.
This function is called before the component unmounts or when dependencies change.
Hooks
Lifecycle

Remember Class lifecycle methods, we can make connections with React hooks




Practical Work

Practical work
We need to buy these items!
Add a ownedItems state
In <Game />
add a ownedItems to store items you bought.
Handle the click on the buy button
Add an occurrence of the item to the ownedItems when the user clicks on the "buy" button. An item can be bought several time.
Buying items has a cost
Each item has a price. Decrement the amount of line you own according the item's price.
Items generates lines of code!
Each item has a line per millisecond indice. It represents the amount of lines created by the item each milliseconds.
useEffect(() => {
const interval = setInterval(() => {
// Do something
}, 100)
return () => clearInterval(interval)
})
Create the Office
Create an office component that displays the amount of items you own.

Practical work
Correction <Game />
// ./src/components/Game.jsx
import { useEffect, useState } from "react";
import "./Game.css";
import items from "@/items.json";
import { Gitcoin } from "./Gitcoin";
import { Office } from "./Office";
import { Score } from "./Score";
import { Store } from "./Store";
export function Game() {
const [lines, setLines] = useState(0);
const [linesPerMillisecond, setLinesPerMillisecond] = useState(0);
const [ownedItems, setOwnedItems] = useState({});
useEffect(() => {
const interval = setInterval(() => {
setLines(lines + linesPerMillisecond);
}, 100);
return () => clearInterval(interval);
}, [lines, linesPerMillisecond]);
useEffect(() => {
let count = 0;
for (const name of Object.keys(ownedItems)) {
const item = items.find((element) => element.name === name);
count += item.linesPerMillisecond * ownedItems[name];
}
setLinesPerMillisecond(count);
}, [ownedItems]);
const handleClick = () => {
setLines(lines + 1);
};
const handleBuy = (item) => {
setLines(lines - item.price);
setOwnedItems({
...ownedItems,
[item.name]: (ownedItems[item.name] || 0) + 1,
});
};
return (
<main className="game">
<section className="left">
<Score
lines={Math.ceil(lines)}
linesPerSecond={Math.ceil(linesPerMillisecond * 10)}
/>
<Gitcoin onClick={handleClick} />
</section>
<section className="center">
<Office items={ownedItems} />
</section>
<section className="right">
<Store lines={lines} onBuy={handleBuy} />
</section>
</main>
);
}
.game {
display: flex;
flex-direction: row;
justify-content: space-between;
margin: 2rem 20rem;
}
.game .left {
display: flex;
flex-direction: column;
align-items: center;
}
Game.css

Practical work
Correction <Store />
// ./src/components/Store.jsx
import "./Store.css";
import items from "@/items.json";
export function Store({ lines, onBuy }) {
const canBuy = (item) => {
return lines >= item.price;
};
return (
<ul>
{items.map((item) => (
<li key={item.name} className="item">
<span>
{item.name} - {item.price}
</span>
<button
onClick={() => onBuy(item)}
disabled={!canBuy(item)}
type="button"
>
Buy
</button>
</li>
))}
</ul>
);
}

Practical work
Correction <Office />
// ./src/components/Office.jsx
export function Office({ items }) {
return (
<>
<h2>Office</h2>
<ul>
{Object.keys(items).map((name) => (
<li key={name}>
<span>
{items[name]} {name}
</span>
</li>
))}
</ul>
</>
);
}

Practical work
Correction <Score />
// ./src/components/Score.jsx
export function Score({ lines, linesPerSecond }) {
return (
<>
<h3 style={{ fontFamily: "Orbitron" }}>{lines} lines</h3>
<small>per second: {linesPerSecond}</small>
</>
);
}

06
TypeScript
Adding types validation within your JavaScript code


TypeScript
It lacks some type checking!
We now have several components that each required specific props.
// ./src/components/Store.js
import './Store.css'
import items from '@/items.json'
export const Store = ({ lines, onBuy }) => {
const canBuy = item => {
return lines >= item.price
}
return (
<ul>
{items.map((item, key) =>
<li key={key} className="item">
<span>{item.name} - {item.price}</span>
<button
onClick={() => onBuy(item)}
disabled={!canBuy(item)}
>
Buy
</button>
</li>
)}
</ul>
)
}

// ./src/components/Game.js
return (
<main className="game">
...
<section className="right">
<Store
lines={lines}
onBuy={handleBuy}
/>
</section>
...
</main>
)
Must be an number
Must be a function
PropTypes
prop-types to the rescue!

Library created by Facebook
Although it is no longer included in React since version 15.5 now lives as a separated library, and archived by the Facebook on Sep 1, 2024
Allows defining type requirements for props.
prop-types lets you define the types of each props of a component.
Integrates well with linters
ESLint has rules to check React PropTypes
import "./Store.css";
import items from "@/items.json";
import PropTypes from 'prop-types';
export function Store({ lines, onBuy }) {
const canBuy = (item) => {
return lines >= item.price;
};
return (
// ...
);
}
Store.propTypes = {
lines: PropTypes.number.isRequired,
onBuy: PropTypes.func.isRequired
};
TypeScript
We're in 2025, and TypeScript is here to save the day! 🚀

TypeScript is a superset of JavaScript, meaning all JavaScript code is valid TypeScript.
- It preserves JavaScript's runtime behavior while adding a powerful type system on top.
- Type safety: TypeScript infers primitive types (
number
,string
,object
, etc.) and prevents common errors. - Custom types: You can define your own types to handle complex data structures and improve code maintainability.
- Better tooling: Enjoy autocompletion, refactoring support, and instant feedback in your editor.
By adding static type checking, TypeScript helps catch errors early, making your code more reliable and easier to maintain
TypeScript
Install TypeScript

pnpm add -D typescript
pnpm add -D @types/node
pnpm add -D @types/react
pnpm add -D @types/react-dom
Node and react types
Create the following files
// package.json
{
// ...
"scripts": {
"build": "tsc -b && vite build",
},
// ...
}
Update your build
script
/// <reference types="vite/client" />
Create file src/vite-env.d.ts
// tsconfig.json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
TypeScript configuration
TypeScript

TypeScript configuration
// tsconfig.app.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
// tsconfig.node.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
TypeScript

Replace your JavaScript files extension
pnpm tsc -b
Replace all your .js
extension by .ts
and .jsx
extension by .tsx
Run TypeScript type checking
TypeScript
You may have some type errors


TypeScript

Some primitive types
- number : all numbers (float, integer)
- string
- boolean
- null or undefined
We can define the type of a variable using ": <type>"
let isChecked: boolean
isChecked = true
or
let id: number = 10
id = true // Error: TypeScript expect id to be a number
But usually, TypeScript is smart enough to infer the type of a variable by is value
let isChecked = false // TS knows it's a boolean
let name = 'toto' // TS knows it's a string
let id = 10 // Ts knows it's a number
id = false // Error: TypeScript expect id to be a number
TypeScript

Union of types
TypeScript allows you to build new types out of existing ones using a variety of operators.
The Union operator "|" can be used to define a variable that can accept any type of value defined by the union
let zipCode: number | string
zipCode = 14000 // OK
zipCode = "14000" // OK
zipCode = false // Error: TypeScript expect zipCode to be a number or a string
TypeScript

Array type
To specify the type of an array, you can use the following syntax with "[]"
let list: number[] = [1, 2, 3] // list is an array of number
You can use the union operator, so that your array will accept more than one type
let list: (number | string)[];
list = [1, '2', 3]; // OK
list = [1, '2', true]; // KO
TypeScript is able to infer types
let list = [1, '2', 3] // TypeScript knows list is typed as (number | string)[]
let list: Array<number | string>;
list = [1, '2', 3]; // OK
list = [1, '2', true]; // KO
alternatives
TypeScript

Object type
To specify the type of an object, you simply type each of its properties
let book: { id: string, title: string, nbOfPages: number }
book = {
id: '1',
title: 'Harry Potter à l\'école des sorciers',
nbOfPages: 305
}
TypeScript

Function type
TypeScript allows you to specify types of both the input and the output of a function
function explode(text: string, delimiter: string): string[] {
return text.split(delimiter)
// if we replace the return by 'return true'
// we will have a TypeScript error because the return value must an array of string
}
explode('chat,chien,lapin', ',') // return ['chat', 'chien', 'lapin']
explode('chat,chien, lapin', 2) // Error: TypeScript expect argument 2 to be a string
const explode = (text: string, delimiter: string): string[] => {
return text.split(delimiter)
}
Syntax with arrow function
TypeScript

Type Aliases
TypeScript lets you define an alias for a specific type using "type
" keyword.
It can then be reused in your code, making the typing of your variables and methods lighter and less verbose.
const bookCollection: {
id: string,
title: string,
nbOfPages: number | null
} [] = [{
id: '1',
title: 'Harry Potter à l\'école des sorciers',
nbOfPages: 305
}]
function readBookCollection(books: {
id: string,
title: string,
nbOfPages: number | null
}[]) : void {
books.forEach((book) => alert(book.title))
}
type Book = {
id: string;
title: string;
nbOfPages: number | null;
}
const bookCollection: Book[] = [{
id: '1',
title: 'Harry Potter à l\'école des sorciers',
nbOfPages: 305
}]
function readBookCollection(books: Book[]) : void {
books.forEach((book) => alert(book.title))
}
TypeScript

"any" type
- It's the most generic type and it accepts any one of the others.
- When TypeScript doesn't know how to infer the type of a property, it types it as "any".
- It's not recommended to use this type because it generally defeats the purpose of using TypeScript.
- That's why we have
"strict": true
within thetsconfig.json
file

TypeScript

Type within a react component
In a react component, you should add some types on yours props and states
import { useState } from React
// define a custom type alias for your props
type Props {
name: string;
label: string;
disabled?: boolean;
}
function Checkbox({
name,
label,
disabled = false
}: Props) {
const [checked, setChecked] = useState<boolean>(false)
const handleChange = function() {
setChecked(!checked)
}
return (
<div>
<input
id='input-id'
name={name}
type="checkbox"
checked={checked}
onChange={handleChange}
disabled={disabled}
/>
<label htmlFor='input-id'>{label}</label>
</div>
);
}

type Props {
disabled: undefined | boolean;
}
=
type Props {
disabled?: boolean;
}
export function Checkbox({
disabled = false
}: Props) {
...
}
Defining a default value
Practical Work

Practical work
Add TypeScript to your project and fix type errors

- If you use a same type alias in many components, extract and export it from a separated file src/type.ts

Practical work
Correction src/types.ts
// ./src/types.ts
export type Item = {
name: string;
price: number;
linesPerMillisecond: number;
};
export type OwnedItems = {
[key: string]: number;
};

Practical work
Correction <Gitcoin />
// ./src/components/Gitcoin.tsx
import "./Gitcoin.css";
import githubIcon from "@/assets/github.svg";
type Props = {
onClick: () => void;
};
export function Gitcoin({ onClick }: Props) {
return (
<button className="gitcoin" onClick={onClick} type="button">
<img src={githubIcon} alt="Gitcoin" />
</button>
);
}

Practical work
Correction <Office />
// ./src/components/Office.tsx
import type { OwnedItems } from "@/types";
type Props = {
items: OwnedItems;
};
export function Office({ items }: Props) {
return (
<>
<h2>Office</h2>
<ul>
{Object.keys(items).map((name) => (
<li key={name}>
<span>
{items[name]} {name}
</span>
</li>
))}
</ul>
</>
);
}


Look at the import type
, it's good practice in scripts to specify that you're importing/exporting a type.
Practical work
Correction <Score />
// ./src/components/Score.tsx
type Props = {
lines: number;
linesPerSecond: number;
};
export function Score({ lines, linesPerSecond }: Props) {
return (
<>
<h3 style={{ fontFamily: "Orbitron" }}>{lines} lines</h3>
<small>per second: {linesPerSecond}</small>
</>
);
}

Practical work
Correction <Store />
// ./src/components/Store.tsx
import type { Item } from "@/types";
import "./Store.css";
import items from "@/items.json";
type Props = {
lines: number;
onBuy: (item: Item) => void;
};
export function Store({ lines, onBuy }: Props) {
const canBuy = (item: Item) => {
return lines >= item.price;
};
return (
<ul>
{items.map((item) => (
<li key={item.name} className="item">
<span>
{item.name} - {item.price}
</span>
<button
onClick={() => onBuy(item)}
disabled={!canBuy(item)}
type="button"
>
Buy
</button>
</li>
))}
</ul>
);
}

Practical work
Correction <Game />
// ./src/components/Game.tsx
import { useEffect, useState } from "react";
import "./Game.css";
import items from "@/items.json";
import type { Item, OwnedItems } from "@/types";
import { Gitcoin } from "./Gitcoin";
import { Office } from "./Office";
import { Score } from "./Score";
import { Store } from "./Store";
export function Game() {
const [lines, setLines] = useState(0);
const [linesPerMillisecond, setLinesPerMillisecond] = useState(0);
const [ownedItems, setOwnedItems] = useState<OwnedItems>({});
useEffect(() => {
const interval = setInterval(() => {
setLines(lines + linesPerMillisecond);
}, 100);
return () => clearInterval(interval);
}, [lines, linesPerMillisecond]);
useEffect(() => {
let count = 0;
for (const name of Object.keys(ownedItems)) {
const item = items.find((element) => element.name === name);
if (item != null) {
count += item.linesPerMillisecond * ownedItems[name];
}
}
setLinesPerMillisecond(count);
}, [ownedItems]);
const handleClick = () => {
setLines(lines + 1);
};
const handleBuy = (item: Item) => {
setLines(lines - item.price);
setOwnedItems({
...ownedItems,
[item.name]: (ownedItems[item.name] || 0) + 1,
});
};
return (
<main className="game">
<section className="left">
<Score
lines={Math.ceil(lines)}
linesPerSecond={Math.ceil(linesPerMillisecond * 10)}
/>
<Gitcoin onClick={handleClick} />
</section>
<section className="center">
<Office items={ownedItems} />
</section>
<section className="right">
<Store lines={lines} onBuy={handleBuy} />
</section>
</main>
);
}

Practical work
Correction index.tsx
import { Game } from "@/components/Game";
import React from "react";
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root") as HTMLElement);
root.render(
<React.StrictMode>
<Game />
</React.StrictMode>,
);

ESLint

Install ESLint and its plugins
pnpm add -D @eslint/js eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-react-refresh glo
bals typescript-eslint @stylistic/eslint-plugin
// eslint.config.js
import js from '@eslint/js'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import stylistic from '@stylistic/eslint-plugin'
export default tseslint.config(
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
stylistic.configs.recommended,
{ ignores: ['dist'] },
)
Fix existing errors
pnpm eslint . --fix // or pnpm lint:fix
// package.json
// ...
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
}
// ..

ESLint

// vscode/settings.json
{
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"json",
]
}

For vscode user, add this file
07
Routing
How to handle routing in React Apps with React Router


Routing
Install
Documentation

pnpm add react-router

React Router v7 requires the following minimum versions:
node@20
react@18
react-dom@18
-
Declarative Mode
Provides basic routing features like URL matching, navigation, etc. Ideal for simple applications requiring straightforward routing.
-
Data Mode
Introduces advanced features like data loading, actions, and pending states by moving route configuration outside of React rendering. Utilizes APIs likeloader
,action
, anduseFetcher
. Suitable for applications needing better data handling capabilities.
- Framework Mode
Extends Data Mode by integrating with a Vite plugin to offer a comprehensive React Router experience, including type-safe routes, support for SPA, SSR, and static rendering strategies. Best for developers seeking a full-featured routing framework.
React Router v7 offers three primary modes to suit different application needs:
Choosing a Routing Mode
Routing

Declarative Routing
In Declarative Mode, React Router allows you to define your application's routing structure using JSX components, ensuring a clear and intuitive setup.
import { BrowserRouter, Routes, Route } from 'react-router';
import Home from './Home';
import About from './About';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
);
}
Key Components:
-
<BrowserRouter>
: Wraps your application, enabling HTML5 history API support for clean URLs.React Router -
<Routes>
: A container for all your route definitions. -
<Route>
: Defines a mapping between a URL path and a corresponding React component.
Routing
<Link /> component
The <Link>
component is a wrapper around the standard <a>
tag, enabling client-side navigation without full page reloads
import { Link } from 'react-router'
function Navigation() {
return (
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
)
}

Practical Work

Practical Work
Bootstrap the routing on the application
Create an <App /> component
This component will be the root component of our application.

Create a home route on "/"
This route will display a menu that allows you to navigate to the game.
Create a game route on "/gitclicker"
This route will display the <Game /> component.
import { BrowserRouter, Routes, Route } from 'react-router';
import Home from './Home';
import About from './About';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
);
}
Add a nav section in <Game />
Add a link to go back to the main menu.
Practical Work
Correction <App />

// ./src/App.tsx
import { BrowserRouter, Routes, Route } from 'react-router'
import { Game } from '@/components/Game'
import Home from '@/components/Home'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route index element={<Home />} />
<Route path="gitclicker" element={<Game />} />
</Routes>
</BrowserRouter>
)
}
Practical Work
Correction <Home />

// ./src/components/Home.tsx
import { Link } from 'react-router'
export default function Home() {
return (
<div>
<h1>Welcome to Gitclicker!</h1>
<Link to="/gitclicker">
Start a new game !
</Link>
</div>
)
}
Practical Work
Correction <Game />

// .src/components/Game.tsx
import { useEffect, useState } from 'react'
import './Game.css'
import items from '@/items.json'
import type { Item, OwnedItems } from '@/types'
import { Gitcoin } from './Gitcoin'
import { Office } from './Office'
import { Score } from './Score'
import { Store } from './Store'
import { Link } from 'react-router'
export function Game() {
const [lines, setLines] = useState(0)
const [linesPerMillisecond, setLinesPerMillisecond] = useState(0)
const [ownedItems, setOwnedItems] = useState<OwnedItems>({})
useEffect(() => {
const interval = setInterval(() => {
setLines(lines + linesPerMillisecond)
}, 100)
return () => clearInterval(interval)
}, [lines, linesPerMillisecond])
useEffect(() => {
let count = 0
for (const name of Object.keys(ownedItems)) {
const item = items.find(element => element.name === name)
if (item != null) {
count += item.linesPerMillisecond * ownedItems[name]
}
}
setLinesPerMillisecond(count)
}, [ownedItems])
const handleClick = () => {
setLines(lines + 1)
}
const handleBuy = (item: Item) => {
setLines(lines - item.price)
setOwnedItems({
...ownedItems,
[item.name]: (ownedItems[item.name] || 0) + 1,
})
}
return (
<>
<Link to="/">
Back to home
</Link>
<main className="game">
<section className="left">
<Score
lines={Math.ceil(lines)}
linesPerSecond={Math.ceil(linesPerMillisecond * 10)}
/>
<Gitcoin onClick={handleClick} />
</section>
<section className="center">
<Office items={ownedItems} />
</section>
<section className="right">
<Store lines={lines} onBuy={handleBuy} />
</section>
</main>
</>
)
}
Routing



The app looks great right ?

Let's add some design


Design
Design is time consuming!
Clone the following repository
Let me handle this for you.
git clone --depth 1 --branch v0.8 https://github.com/KnpLabs/training-react-gitclicker.git
Install and run
cd training-react-gitcliker
yarn install
yarn start


Design
What is under the hood ?
Material UI
A React library providing material design components.
Devicon
A set of icons representing programming languages.
Rename <Office />
Office has been renamed to <Skills />


Reorganized code
The architecture is closed to a real projet.
I'm losing all my lines when I go back to the menu!


Routing

Routing
Components are unmounted when the route changes
States are lost
When a component is unmounted its internal states are destroyed
Effects cleanup functions are triggered
All cleanup functions within the unmounted components are executed.
Effects only on mount are reset
If there is some effect that are executed only once on mount, they will be re-triggered as soon as the component will be mounted again.
useEffect(() => {
// do something
}, []);

We need a solution to handle data from outside the <Game /> component. It has become to complex anyway.
08
Redux
Centralizing your application's state and logic enables powerful capabilities like undo/redo, state persistence, and much more.



Redux
Store
The store is an object that represents the global state of the application
Reducer
The reducer is a function that receives 2 arguments: the current state and a data payload. Its responsibility is to mutate the state according the received payload.

Redux
State
import { useDispatch } from 'react-redux'
import { increment } from '@/modules/game'
export function MyComponent() {
const dispatch = useDispatch()
const handleClick = () => dispatch(increment())
return (
<button onClick={handleClick}>
Increment
</button>
)
}
{
"counter": 0,
"article": {
"title": "lorem impsum"
}
}
You make a call to the reducers from your components
const counter = createSlice({
name: 'counter',
initialState: INITIAL_STATE,
reducers: {
increment: (state) => {
return {
...state,
counter: state.counter + 1,
}
},
},
})
Reducer
New state
{
"counter": 1,
"article": {
"title": "lorem impsum"
}
}

Redux
Architecture
Separate the logic by module
One of the main advantage of redux is that you can use nested reducers to split the logic into smaller pieces.
Creating a store module
import { PayloadAction, createSlice } from '@reduxjs/toolkit'
// Initial state
type CartState = {
products: string[]
totalPrice: number
}
const INITIAL_STATE: CartState = {
products: [],
totalPrice: 0,
}
const cart = createSlice({
name: 'cart',
initialState: INITIAL_STATE,
reducers: {
addProduct: (state, action: PayloadAction<string>) => {
state.products.push(action.payload)
},
removeProduct: (state, action: PayloadAction<string>) => {
state.products = state.products.filter(
product => product !== action.payload,
)
},
},
})
export const { addProduct, removeProduct } = cart.actions
export default cart
You can create a module using the createSlice method from @reduxjs/toolkit. The method takes 3 parameters :
- A name string, to uniquely identify your module.
- An initial state.
- A reducers object, where each attributes is a reducer function that can be used to process mutation on the state.

Redux
Usage in component - Hooks
import { useSelector, useDispatch } from 'react-redux'
import { increment } from '@/modules/game'
export function MyComponent() {
const counter = useSelector((state: RootState) => state.game.counter)
const dispatch = useDispatch()
const handleClick = () => {
dispatch(increment())
}
return (
<>
<span>{counter}</span>
<button onClick={handleClick}>Increment</button>
</>
)
}
useSelector
Allows to pick some values from the redux store. The component is re-rendered as soon as one of theses values is updated.
useDispatch
Allows to dispatch some modules actions that will be handle by the root reducer. The create slice module automatically create an action for each reducers of your module.
Dispatching an action will call the reducer with the same name

Redux
Get started
pnpm add @reduxjs/toolkit react-redux
Install
Configure your root reducer
In the src/modules/index.ts file
Create a store.ts
in the src folder
import { configureStore } from '@reduxjs/toolkit'
import { rootReducer } from './modules'
function createStore() {
return configureStore({
reducer: rootReducer,
})
}
const store = createStore()
export type RootState = ReturnType<typeof store.getState>
export default store
import { combineReducers } from '@reduxjs/toolkit'
import { reducer as gameReducer } from './game'
export const rootReducer = combineReducers({
game: gameReducer,
})
The file src/modules/game.ts will contain our module that handles the logic of the clicker game.

Redux
Add the provider to App component
// main.tsx
// ...
import { Provider } from 'react-redux'
import store from './store'
const root = createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
// ...
<Provider store={store}>
<App />
</Provider>
// ...
</React.StrictMode>,
)
Practical Work


Practical Work
Create the game module, src/modules/game.ts
Add the following actions
- CLICK
- BUY_ITEM
- LOOP
And the according action creators, initial state and reducer
const INITIAL_STATE = {
lines: 0,
linesPerMillisecond: 0,
skills: {},
}
Update the <Game /> component
To use the react-redux hooks instead of local states.

Practical Work
Correction modules/game.ts
import type { OwnedItems, Item } from '@/types'
import { PayloadAction, createSlice } from '@reduxjs/toolkit'
// Initial state
type GameState = {
lines: number
linesPerMillisecond: number
skills: OwnedItems
}
const INITIAL_STATE: GameState = {
lines: 0,
linesPerMillisecond: 0,
skills: {},
}
const game = createSlice({
name: 'game',
initialState: INITIAL_STATE,
reducers: {
click: (state) => {
state.lines += 1
},
buyItem: (state, action: PayloadAction<Item>) => {
const { name, price, linesPerMillisecond: itemLinesPerMillisecond } = action.payload
return {
...state,
lines: state.lines - price,
linesPerMillisecond: state.linesPerMillisecond + itemLinesPerMillisecond,
skills: {
...state.skills,
[name]: (state.skills[name] || 0) + 1,
},
}
},
loop: (state) => {
state.lines += state.linesPerMillisecond
},
},
})
export const { click, buyItem, loop } = game.actions
export default game.reducer

Practical Work
Correction modules/index.ts
import { combineReducers } from '@reduxjs/toolkit'
import game from './game'
export const rootReducer = combineReducers({
game,
})

Practical Work
Correction store.ts
import { configureStore } from '@reduxjs/toolkit'
import { rootReducer } from './modules'
function createStore() {
return configureStore({
reducer: rootReducer,
})
}
const store = createStore()
export type RootState = ReturnType<typeof store.getState>
export default store

Practical Work
Correction main.tsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import { CssBaseline } from '@mui/material'
import { ThemeProvider } from '@mui/material/styles'
import theme from '@/styles/theme'
import { Provider } from 'react-redux'
import store from './store'
const root = createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<Provider store={store}>
<CssBaseline />
<App />
</Provider>
</ThemeProvider>
</React.StrictMode>,
)

Practical Work
Correction <Game />
import '@/styles/game/index.css'
import { useEffect } from 'react'
import type { Item } from '@/types'
import { Grid2 as Grid, Card, CardContent, CardHeader } from '@mui/material'
import { Score, Gitcoin } from '@/components/game/core'
import { Skills } from '@/components/game/skills'
import { Store } from '@/components/game/store'
import { loop, click, buyItem } from '@/modules/game'
import { RootState } from '@/store'
import { useDispatch, useSelector } from 'react-redux'
export function Game() {
const dispatch = useDispatch()
const lines = useSelector((state: RootState) => state.game.lines)
const skills = useSelector((state: RootState) => state.game.skills)
const linesPerMillisecond = useSelector((state: RootState) => state.game.linesPerMillisecond)
useEffect(() => {
const interval = setInterval(() => {
dispatch(loop())
}, 100)
return () => clearInterval(interval)
}, [])
const handleClick = () => dispatch(click())
const handleBuy = (item: Item) => dispatch(buyItem(item))
return (
<>
<Grid size={3}>
<Card component="section" className="card">
<CardContent className="content">
<Score
lines={Math.ceil(lines)}
linesPerSecond={Math.ceil(linesPerMillisecond * 10)}
/>
<Gitcoin onClick={handleClick} />
</CardContent>
</Card>
</Grid>
<Grid size="grow">
<Card component="section" className="card">
<CardHeader title="Skills" />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Skills skills={skills} />
</CardContent>
</Card>
</Grid>
<Grid size="grow">
<Card component="section" className="card">
<CardHeader title="Store" />
<CardContent>
<Store lines={lines} onBuy={handleBuy} />
</CardContent>
</Card>
</Grid>
</>
)
}

Practical Work
Redux DevTools extension
Redux DevTools for debugging application's state changes.


Practical Work


Practical Work
<Game /> still handles too many responsibilities
The CLICK action
Should be handled by the <Gitcoin /> itself
The BUY_ITEM action
Should be handled by the <Store /> itself
Selectors
<Game /> should not pick data from the store just in order to pass them to child components. Sub components should pick theses data by themselves.

Practical Work
Correction <Score />
import { useSelector } from 'react-redux'
import { RootState } from '@/store'
export const Score = () => {
const lines = useSelector((state: RootState) => state.game.lines)
const linesPerMillisecond = useSelector((state: RootState) => state.game.linesPerMillisecond)
return (
<>
<h3 style={{ fontFamily: 'Orbitron' }}>
{Math.ceil(lines)}
{' '}
lines
</h3>
<small>
per second:
{Math.ceil(linesPerMillisecond * 10)}
</small>
</>
)
}

Practical Work
Correction <Store />
import { Item as ItemType } from '@/types'
import { Item } from './Item.tsx'
import { items } from '@/constants/items.ts'
import { Grid2 as Grid } from '@mui/material'
import { buyItem } from '@/modules/game.ts'
import { RootState } from '@/store.ts'
import { useSelector, useDispatch } from 'react-redux'
export function Store() {
const lines = useSelector((state: RootState) => state.game.lines)
const dispatch = useDispatch()
const handleBuy = (item: ItemType) => dispatch(buyItem(item))
return (
<Grid container component="ul" spacing={2} display="flex" flexDirection="column">
{items.map((item, key) => (
<Item
key={key}
item={item}
lines={lines}
onBuy={handleBuy}
/>
))}
</Grid>
)
}

Practical Work
Correction <Gitcoin />
import '@/styles/game/core/gitcoin.css'
import githubIcon from '@/assets/github.svg'
import { click } from '@/modules/game'
import { useDispatch } from 'react-redux'
export function Gitcoin() {
const dispatch = useDispatch()
const handleClick = () => dispatch(click())
return (
<button className="gitcoin" onClick={handleClick} type="button">
<img src={githubIcon} alt="Gitcoin" />
</button>
)
}

Practical Work
Correction <Skills />
import { RootState } from '@/store'
import { useSelector } from 'react-redux'
import { Section } from './Section'
export const Skills = () => {
const skills = useSelector((state: RootState) => state.game.skills)
return (
<>
{Object.keys(skills).map(name => (
<Section
key={name}
itemName={name}
number={skills[name]}
/>
))}
</>
)
}

Practical Work
Correction <Game />
import '@/styles/game/index.css'
import { useEffect } from 'react'
import { Grid2 as Grid, Card, CardContent, CardHeader } from '@mui/material'
import { Score, Gitcoin } from '@/components/game/core'
import { Skills } from '@/components/game/skills'
import { Store } from '@/components/game/store'
import { loop } from '@/modules/game'
import { useDispatch } from 'react-redux'
export function Game() {
const dispatch = useDispatch()
useEffect(() => {
const interval = setInterval(() => {
dispatch(loop())
}, 100)
return () => clearInterval(interval)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<>
<Grid size={3}>
<Card component="section" className="card">
<CardContent className="content">
<Score />
<Gitcoin />
</CardContent>
</Card>
</Grid>
<Grid size="grow">
<Card component="section" className="card">
<CardHeader title="Skills" />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Skills />
</CardContent>
</Card>
</Grid>
<Grid size="grow">
<Card component="section" className="card">
<CardHeader title="Store" />
<CardContent>
<Store />
</CardContent>
</Card>
</Grid>
</>
)
}
09
Unit testing
Test reducers and components with Vitest and testing-library



Unit testing
Vitest
A blazing fast unit testing framework for modern frontend tooling
Designed for Vite
Vitest is built with Vite in mind. It benefits from Vite’s fast module resolution and HMR.
Built-in mockin
Powerful and flexible mocking system, similar to Jest, with native support for ES modules.
Code coverage
No additional setup needed. Vitest can collect code coverage information from entire projects, including untested files.
First-class TypeScript support
Zero-config TypeScript support, including typings in test files and assertion helpers.
Jest-compatible API
The test syntax is largely compatible with Jest (describe
, it
, expect
, etc.), which makes migration easier.

Unit testing
Jest - Eslint configuration
Install the following libraries
pnpm add -D vitest @vitest/coverage-v8 @vitest/eslint-plugin @testing-library/react @testing-library/jest-dom @testing-library/user-event eslint-plugin-testing-library jsdom
Add the following script in your package.json
{
// ...
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"preview": "vite preview",
"test": "vitest",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
}
{
"compilerOptions": {
// ...
/* Testing */
"types": ["vitest/globals"]
},
// ...
}
Add the following in your tsconfig.app.json

Unit testing
Jest - Eslint configuration
Create ./src/test-setup.ts
import '@testing-library/jest-dom/vitest'
Update the vite.config.ts
file
/// <reference types="vitest/config" />
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test-setup.ts',
coverage: {
reporter: ['text'],
},
},
})
Update the eslint.config.js
file
import js from '@eslint/js'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import stylistic from '@stylistic/eslint-plugin'
import vitest from '@vitest/eslint-plugin'
import testingLibrary from 'eslint-plugin-testing-library'
export default tseslint.config(
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
stylistic.configs.recommended,
{
files: ['**/*.test.ts?(x)'],
languageOptions: {
globals: {
...vitest.environments.env.globals,
},
},
plugins: {
vitest,
'testing-library': testingLibrary,
},
rules: {
...vitest.configs.recommended.rules,
...testingLibrary.configs['flat/react'].rules,
},
},
{ ignores: ['dist'] },
)

Unit testing
Vitest - Usage
function numberWithDot(x: number): string {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.')
}
function numberFormat(x: number | string): string {
const number = parseInt(x.toString())
if (number < 1000000) return numberWithDot(number)
const numberOfMillion = Math.floor(number / 1000000 * 1000) / 1000
return `${numberWithDot(numberOfMillion)} millions`
}
export default numberFormat
import numberFormat from './numberFormat'
describe('numberFormat', () => {
it('format numbers bellow 1.000', () => {
const number = 234
expect(numberFormat(number)).toBe('234')
})
it('format numbers bellow 1.000.000', () => {
const number = 123234
expect(numberFormat(number)).toBe('123.234')
})
it('format numbers above 1.000.000', () => {
const number = 12123234
expect(numberFormat(number)).toBe('12.123 millions')
})
})
Practical Work


Practical work
Test the game.ts module
Test reducer
Try to reach 100% of test coverage.
// ./src/modules/game.test.ts
import game, { buyItem, click, loop } from './game'
describe('game reducer', () => {
it('should handle loop action', () => { /* to complete */ })
it('should handle click action', () => { /* to complete */ })
it('should handle buyItem action, with no existing skills', () => { /* to complete */ })
it('should handle buyItem action, when the skill has already been bought', () => { /* to complete */ })
it('should handle buyItem action, when another skill has already been bought', () => { /* to complete */ })
it('should handle unknown action', () => { /* to complete */ })
})
pnpm test:coverage

Practical work
Correction
import game, { buyItem, click, loop } from '../game'
describe('game reducer', () => {
it('should handle loop action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
}
const action = loop()
const expectedState = {
lines: 12,
linesPerMillisecond: 6,
skills: {},
}
expect(game(state, action)).toEqual(expectedState)
})
it('should handle click action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
}
const action = click()
const expectedState = {
lines: 7,
linesPerMillisecond: 6,
skills: {},
}
expect(game(state, action)).toEqual(expectedState)
})
it('should handle buyItem action, with no existing skills', () => {
const item = {
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg',
}
const action = buyItem(item)
const state = {
lines: 25,
linesPerMillisecond: 6,
skills: {},
}
const expectedState = {
lines: 15,
linesPerMillisecond: 6.5,
skills: {
Bash: 1,
},
}
expect(game(state, action)).toEqual(expectedState)
})
it('should handle buyItem action, when the skill has already been bought', () => {
const item = {
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg',
}
const action = buyItem(item)
const state = {
lines: 25,
linesPerMillisecond: 6,
skills: {
Bash: 4,
},
}
const expectedState = {
lines: 15,
linesPerMillisecond: 6.5,
skills: {
Bash: 5,
},
}
expect(game(state, action)).toEqual(expectedState)
})
it('should handle buyItem action, when another skill has already been bought', () => {
const item = {
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg',
}
const action = buyItem(item)
const state = {
lines: 25,
linesPerMillisecond: 6,
skills: {
Bash: 4,
Javascript: 2,
Vim: 1,
},
}
const expectedState = {
lines: 15,
linesPerMillisecond: 6.5,
skills: {
Bash: 5,
Javascript: 2,
Vim: 1,
},
}
expect(game(state, action)).toEqual(expectedState)
})
it('should handle unknown action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
}
const action = { type: 'UNKNOWN ACTION' }
expect(game(state, action)).toEqual(state)
})
})

Unit testing
React Testing Library
Avoid testing implementation details
Built on top of react-dom
, React Testing Library renders components into a virtual DOM, allowing you to make assertions based on what the user would see and do.
Test React components effectively
RTL encourages testing your components the way users interact with them.
Instead of accessing internal APIs or inspecting component state, you gain more confidence by writing tests based on the rendered output and user behavior.


Unit testing
React Testing Library - Usage
export const Greeter = ({ name }: { name: string }) => {
return (
<p>Hello {name}! Nice to meet you!</p>
)
}
// Greeter.test.tsx
import { Greeter } from './Greeter'
import { render, screen } from '@testing-library/react'
describe('Greeter', () => {
it('greets someone', () => {
render(<Greeter name="John" />)
expect(screen.getByText(/Hello John!/i)).toBeInTheDocument()
})
})

Unit testing
React Testing Library - Usage with react-router
import { MemoryRouter as Router } from 'react-router'
import { Greeter } from './Greeter'
import { render, screen } from '@testing-library/react'
describe('Greeter', () => {
it('greets someone', () => {
render(
<Router>
<Greeter name="John" />
</Router>
)
expect(screen.getByText(/Hello John!/i)).toBeInTheDocument()
})
})
If a component is using react-router or react-router-dom internally (components or hooks) there is a good chance that you need to wrap your component in a <Router /> in order to be tested.

Note that we are using the MemoryRouter which only handles the history in memory instead of in a real BrowserRouter.

Unit testing
React Testing Library - Usage with redux
import { Score } from '../Score'
import { Provider } from 'react-redux'
import { createStore } from '@/store'
import { render, screen } from '@testing-library/react'
describe('Score', () => {
it('should displays the number of lines', () => {
const initialState = {
game: { lines: 6, linesPerMillisecond: 2, skills: {} },
}
render(
<Provider store={createStore(initialState)}>
<Score />
</Provider>,
)
expect(screen.getByText(/6 lines/i)).toBeInTheDocument()
})
})
If a component is using redux internally there is also a good chance that you need to add wrap your component in a <Provider /> in order to be tested.
import { configureStore } from '@reduxjs/toolkit'
import { rootReducer } from './modules'
export function createStore(preloadedState = {}) {
return configureStore({
reducer: rootReducer,
preloadedState,
})
}
const store = createStore()
export type RootState = ReturnType<typeof store.getState>
export default store
src/store.ts

Unit testing
// src/test-setup.tsx
import '@testing-library/jest-dom/vitest'
import { ReactElement } from 'react'
import { render, RenderOptions } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { Provider } from 'react-redux'
import { createStore, RootState } from '@/store'
const customRender = (
ui: ReactElement,
{
preloadedState,
...options
}: RenderOptions & { preloadedState?: Partial<RootState> } = {},
) => {
const store = createStore(preloadedState)
return render(ui, {
wrapper: ({ children }) => (
<Provider store={store}>
<MemoryRouter>{children}</MemoryRouter>
</Provider>
),
...options,
})
}
export * from '@testing-library/react'
export { customRender as render }

When testing components that rely on Redux or React Router or any other provider, you can centralize all necessary providers in a customRender
utility.
This keeps your tests clean and makes it easy to inject things like preloaded state or custom routes.
Good to know
// vite.config.ts
export default defineConfig({
// ...
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test-setup.tsx',
coverage: {
reporter: ['text'],
},
},
})
Practical Work


Practical work
Test the following components
Home.tsx
Store.tsx
Item.tsx
Skills.tsx
Section.tsx
Gitcoin.tsx
Game.tsx







GitClicker.tsx (snapshot)

Navbar.tsx (snapshot)


Practical work
Correction <Home />
import { render, screen } from '@/test-setup'
import Home from '../Home'
describe('Home', () => {
it('renders correctly', () => {
render(
<Home />,
)
expect(screen.getByText(/Dogs have boundless enthusiasm/i)).toBeInTheDocument()
expect(screen.getByText(/Play/i)).toBeInTheDocument()
})
})

Practical work
Correction <GitClicker />
import { render } from '@/test-setup'
import GitClicker from '../GitClicker'
describe('GitClicker page', () => {
it('renders correctly', () => {
const { asFragment } = render(<GitClicker />)
expect(asFragment()).toMatchSnapshot()
})
})
You should have a __snapshots__
folder with GitClicker.test.tsx.snap inside

Practical work
Correction <Store />
import { render, screen } from '@/test-setup'
import { Store } from '../Store'
describe('Store', () => {
it('renders correctly', () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: {},
},
}
render(
<Store />, { preloadedState: initialState },
)
expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/Git/i)).toBeInTheDocument()
expect(screen.getByText(/Javascript/i)).toBeInTheDocument()
expect(screen.getByText(/React/i)).toBeInTheDocument()
expect(screen.getByText(/Vim/i)).toBeInTheDocument()
})
})

Practical work
Correction <Item />
import BashIcon from 'devicon/icons/bash/bash-original.svg'
import { render, screen, fireEvent } from '@testing-library/react'
import { Item } from '../Item'
describe('Item', () => {
it('Renders a buyable item', () => {
const item = {
name: 'Bash',
price: 10,
linesPerMillisecond: 0.1,
icon: BashIcon,
}
const onBuy = vi.fn()
render(
<Item
item={item}
lines={150}
onBuy={onBuy}
/>,
)
expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/1 lines per second/i)).toBeInTheDocument()
expect(screen.getByRole('button')).not.toBeDisabled()
fireEvent.click(screen.getByRole('button'))
expect(onBuy).toHaveBeenCalledWith(item)
})
it('Renders a non buyable item', () => {
const item = {
name: 'Bash',
price: 10,
linesPerMillisecond: 0.1,
icon: BashIcon,
}
const onBuy = vi.fn()
render(
<Item
item={item}
lines={0}
onBuy={onBuy}
/>,
)
expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/1 lines per second/i)).toBeInTheDocument()
expect(screen.getByRole('button')).toBeDisabled()
fireEvent.click(screen.getByRole('button'))
expect(onBuy).not.toHaveBeenCalledWith(item)
})
})

Here, we import render, screen, fireEvent
from @testing-library/react as this component is independent of our providers

Practical work
Correction <Skills />
import { render, screen } from '@/test-setup'
import { Skills } from '../Skills'
describe('Skills', () => {
it('renders correctly', () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: { Bash: 2, Git: 3, Javascript: 4 },
},
}
render(<Skills />, { preloadedState: initialState })
expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/Git/i)).toBeInTheDocument()
expect(screen.getByText(/Javascript/i)).toBeInTheDocument()
})
})

Practical work
Correction <Section />
import { render, screen } from '@testing-library/react'
import { Section } from '../Section'
describe('Section', () => {
it('displays the owned skills', () => {
render(<Section itemName="Bash" number={3} />)
expect(screen.getByText('Bash')).toBeInTheDocument()
expect(screen.getAllByAltText('Bash')).toHaveLength(3)
})
it('render anything on unknown skill', () => {
render(<Section itemName="Unknown" number={3} />)
expect(screen.queryByText('Unknown')).not.toBeInTheDocument()
})
})

Practical work
Correction <Gitcoin />
import { render, screen, fireEvent } from '@/test-setup'
import { Gitcoin } from '../Gitcoin'
import { click } from '@/modules/game'
const mockDispatch = vi.fn()
vi.mock('react-redux', async () => ({
useDispatch: () => mockDispatch,
}))
describe('<Gitcoin />', () => {
it('renders correctly', () => {
render(<Gitcoin />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByAltText(/Gitcoin/i)).toBeInTheDocument()
})
it('dispatches the click action when clicked', () => {
render(<Gitcoin />)
fireEvent.click(screen.getByRole('button'))
expect(mockDispatch).toHaveBeenCalledTimes(1)
expect(mockDispatch).toHaveBeenCalledWith(click())
})
})

Practical work
Correction <Game />
import { Game } from '../Game'
import { render, screen } from '@/test-setup'
describe('Game', () => {
it('renders correctly', async () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: {},
},
}
render(<Game />, { preloadedState: initialState })
expect(screen.getByText(/6 lines/)).toBeInTheDocument()
expect(screen.getByText(/per second: 20/)).toBeInTheDocument()
expect(screen.getByText(/Skills/)).toBeInTheDocument()
expect(screen.getByText(/Store/)).toBeInTheDocument()
await screen.findByText(/8 lines/, undefined, { timeout: 150 })
await screen.findByText(/10 lines/, undefined, { timeout: 150 })
})
})
10
Side Effects
Handle side effects with the redux Thunk middleware



Side Effects
Redux Thunk
Dispatch asynchronous actions
With a simple, basic Redux store, you can only make simple synchronous updates by sending actions. Middleware extends the store's capabilities and lets you write asynchronous logic that interacts with the store.
const game = createSlice({
name: 'game',
initialState: {} as GameState,
reducers: {
initialize: (state, action: PayloadAction<GameState>) => {
return {
...state,
...action.payload,
}
},
},
})
export const loadState = createAsyncThunk(
'game/load',
async (_, { dispatch }) => {
// retrieve data from localStorage
const localStoredState = localStorage.getItem('state')
const initialState = localStoredState
? JSON.parse(localStoredState)
: {}
// dispatch the initialize action to mutate state
dispatch(game.actions.initialize(initialState))
},
)
redux-thunk is already included in redux-toolkit.
Intall
createAsyncThunk
method

Side Effects
Dispatch thunk
With Typescript
Thunk can be dispatch in your component in the same way
as a normal action using the useDispatch
hook.
You'll need to override useDispatch
to help TypeScript understand your thunk.
Now, you have to use useAppDispatch
instead of useDispatch
within our components
// store.ts
import { useDispatch } from 'react-redux'
// ...
export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch
Practical Work


Practical work
Save the game with side effects
Add a start()
side effect
Add a stop()
side effect
In the game.ts
module
This effect searches the local storage for a saved game. If so, it takes this saved game and initializes the game state with it.
This effect will serialize the game module state and store it into the local storage.
Dispatch these two side effects
In the <Game />
component dispatch these two side effects:
-
start()
: on the component mount -
stop()
: on the component unmount

Practical work
Correction store.ts
import { configureStore } from '@reduxjs/toolkit'
import { rootReducer } from './modules'
import { useDispatch } from 'react-redux'
export function createStore(preloadedState = {}) {
return configureStore({
reducer: rootReducer,
preloadedState,
})
}
const store = createStore()
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch
export default store

Practical work
Correction modules/game.ts
import { RootState } from '@/store'
import type { OwnedItems, Item } from '@/types'
import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
// Initial state
type GameState = {
lines: number
linesPerMillisecond: number
skills: OwnedItems
}
const INITIAL_STATE: GameState = {
lines: 0,
linesPerMillisecond: 0,
skills: {},
}
// Side Effects / thunks
export const start = createAsyncThunk(
'game/start',
async (_, { dispatch }) => {
const localStoredGame = localStorage.getItem('game')
const initalGameState = localStoredGame ? JSON.parse(localStoredGame) : {}
dispatch(initGame(initalGameState))
},
)
export const stop = createAsyncThunk(
'game/stop',
async (_, { getState }) => {
const state = getState() as RootState
const serializedGameState = JSON.stringify(state.game)
localStorage.setItem('game', serializedGameState)
},
)
const game = createSlice({
name: 'game',
initialState: INITIAL_STATE,
reducers: {
initGame: (state, action: PayloadAction<GameState>) => {
return {
...state,
...action.payload,
}
},
click: (state) => {
state.lines += 1
},
buyItem: (state, action: PayloadAction<Item>) => {
const { name, price, linesPerMillisecond: itemLinesPerMillisecond } = action.payload
return {
...state,
lines: state.lines - price,
linesPerMillisecond: state.linesPerMillisecond + itemLinesPerMillisecond,
skills: {
...state.skills,
[name]: (state.skills[name] || 0) + 1,
},
}
},
loop: (state) => {
state.lines += state.linesPerMillisecond
},
},
})
export const { click, buyItem, loop, initGame } = game.actions
export default game.reducer

Practical work
Correction <Game />
import '@/styles/game/index.css'
import { useEffect } from 'react'
import { Grid2 as Grid, Card, CardContent, CardHeader } from '@mui/material'
import { Score, Gitcoin } from '@/components/game/core'
import { Skills } from '@/components/game/skills'
import { Store } from '@/components/game/store'
import { loop, start, stop } from '@/modules/game'
import { useAppDispatch } from '@/store'
export function Game() {
const dispatch = useAppDispatch()
useEffect(() => {
dispatch(start())
const interval = setInterval(() => {
dispatch(loop())
}, 100)
return () => {
clearInterval(interval)
dispatch(stop())
}
}, [])
return (
<>
<Grid size={3}>
<Card component="section" className="card">
<CardContent className="content">
<Score />
<Gitcoin />
</CardContent>
</Card>
</Grid>
<Grid size="grow">
<Card component="section" className="card">
<CardHeader title="Skills" />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Skills />
</CardContent>
</Card>
</Grid>
<Grid size="grow">
<Card component="section" className="card">
<CardHeader title="Store" />
<CardContent>
<Store />
</CardContent>
</Card>
</Grid>
</>
)
}

Side Effects
Call an API endpoint
Now we know how to handle side effects. It's time to get rid of the items.ts
file and to use a real API!
git clone https://github.com/KnpLabs/gitclicker-api.git
Clone the API project
cd gitclicker-api
pnpm install
pnpm dev
Install and run
Let's visit : http://localhost:8787/swagger
la branche api n'est pas la bonne
si on garde l'api avec hono.js

Side Effects
Environment variable
Use a .env
file in the root directory
# .env
VITE_API_URL=https://api.acme.com
API_SECRET=someSecretStuff
Exposed to the frontend
Vite exposes environment variables to your application code only if they are prefixed with VITE_
.
// components/MyComponent.ts
console.log(meta.import.env.VITE_API_URL)
// output: https://api.acme.com
console.log(meta.import.env.API_SECRET)
// output: undefined
This variables are accessible to everyone as they are dumped in the bundled JS file. Make sure you are not exposing private keys.
Practical Work


Practical Work
Call an API endpoint
In the start()
side effect: use the fetch()
method to retrieve the items from the API.
Fetch the items from the API
The fetchedItems
reducer handles the fetchedItems
action by updating the store with the list of items retrieved from the API.
Put the items in the store
Update every components that uses the item.js file to pick items from the redux store instead.
Remove usage of items.ts

Don't forget to keep the tests up-to-date
// utils/getItemIcon.ts
import BashIcon from 'devicon/icons/bash/bash-original.svg'
import GitIcon from 'devicon/icons/git/git-original.svg'
import JavascriptIcon from 'devicon/icons/javascript/javascript-original.svg'
import ReactIcon from 'devicon/icons/react/react-original.svg'
import VimIcon from 'devicon/icons/vim/vim-original.svg'
import IEIcon from 'devicon/icons/ie10/ie10-original.svg'
import { Item } from '@/types'
type ItemName = keyof typeof iconMap
const iconMap = {
Bash: BashIcon,
Git: GitIcon,
Javascript: JavascriptIcon,
React: ReactIcon,
Vim: VimIcon,
}
export default (item: Item) => {
return iconMap[item.name as ItemName] ?? IEIcon
}
You can use this script to resolve the item's icon.


Practical Work
Correction src/type.ts
export type Item = {
id: number
name: string
price: number
linesPerMillisecond: number
}
export type OwnedItems = {
[key: string]: number
}

Practical Work
Correction game.ts
import { RootState } from '@/store'
import type { OwnedItems, Item } from '@/types'
import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
// Initial state
type GameState = {
lines: number
linesPerMillisecond: number
skills: OwnedItems
items: Item[]
}
const INITIAL_STATE: GameState = {
lines: 0,
linesPerMillisecond: 0,
skills: {},
items: [],
}
// Side Effects / thunks
export const start = createAsyncThunk(
'game/start',
async (_, { dispatch }) => {
const localStoredGame = localStorage.getItem('game')
const initalGameState = localStoredGame ? JSON.parse(localStoredGame) : {}
dispatch(initGame(initalGameState))
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items`)
const items = await response.json()
dispatch(fetchedItems(items))
},
)
export const stop = createAsyncThunk(
'game/stop',
async (_, { getState }) => {
const state = getState() as RootState
const serializedGameState = JSON.stringify(state.game)
localStorage.setItem('game', serializedGameState)
},
)
const game = createSlice({
name: 'game',
initialState: INITIAL_STATE,
reducers: {
initGame: (state, action: PayloadAction<GameState>) => {
return {
...state,
...action.payload,
}
},
fetchedItems: (state, action: PayloadAction<Item[]>) => {
return {
...state,
items: action.payload,
}
},
click: (state) => {
state.lines += 1
},
buyItem: (state, action: PayloadAction<Item>) => {
const { name, price, linesPerMillisecond: itemLinesPerMillisecond } = action.payload
return {
...state,
lines: state.lines - price,
linesPerMillisecond: state.linesPerMillisecond + itemLinesPerMillisecond,
skills: {
...state.skills,
[name]: (state.skills[name] || 0) + 1,
},
}
},
loop: (state) => {
state.lines += state.linesPerMillisecond
},
},
})
const {
click,
buyItem,
loop,
initGame,
fetchedItems,
} = game.actions
export { click, buyItem, loop }
export default game.reducer

Practical Work
Correction game.test.ts
import gameReducer, { buyItem, click, loop } from '../game'
describe('game reducer', () => {
it('should handle loop action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: [],
}
const action = loop()
const expectedState = {
lines: 12,
linesPerMillisecond: 6,
skills: {},
items: [],
}
expect(gameReducer(state, action)).toEqual(expectedState)
})
it('should handle click action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: [],
}
const action = click()
const expectedState = {
lines: 7,
linesPerMillisecond: 6,
skills: {},
items: [],
}
expect(gameReducer(state, action)).toEqual(expectedState)
})
it('should handle buyItem action, with no existing skills', () => {
const item = {
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg',
}
const action = buyItem(item)
const state = {
lines: 25,
linesPerMillisecond: 6,
skills: {},
items: [],
}
const expectedState = {
lines: 15,
linesPerMillisecond: 6.5,
skills: {
Bash: 1,
},
items: [],
}
expect(gameReducer(state, action)).toEqual(expectedState)
})
it('should handle buyItem action, when the skill has already been bought', () => {
const item = {
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg',
}
const action = buyItem(item)
const state = {
lines: 25,
linesPerMillisecond: 6,
skills: {
Bash: 4,
},
items: [],
}
const expectedState = {
lines: 15,
linesPerMillisecond: 6.5,
skills: {
Bash: 5,
},
items: [],
}
expect(gameReducer(state, action)).toEqual(expectedState)
})
it('should handle buyItem action, when another skill has already been bought', () => {
const item = {
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg',
}
const action = buyItem(item)
const state = {
lines: 25,
linesPerMillisecond: 6,
skills: {
Bash: 4,
Javascript: 2,
Vim: 1,
},
items: [],
}
const expectedState = {
lines: 15,
linesPerMillisecond: 6.5,
skills: {
Bash: 5,
Javascript: 2,
Vim: 1,
},
items: [],
}
expect(gameReducer(state, action)).toEqual(expectedState)
})
it('should handle unknown action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: [],
}
const action = { type: 'UNKNOWN ACTION' }
expect(gameReducer(state, action)).toEqual(state)
})
it('should handle initGame action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: [],
}
const action = {
type: 'game/initGame',
payload: {
lines: 10,
linesPerMillisecond: 10,
skills: {
Bash: 5,
Javascript: 2,
},
items: [
{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg',
},
{
id: 2,
name: 'Git',
price: 100,
linesPerMillisecond: 1.2,
icon: '/some/icon/path.svg',
},
{
id: 3,
name: 'Javascript',
price: 10000,
linesPerMillisecond: 14.0,
icon: '/some/icon/path.svg',
},
],
},
}
const expectedState = {
lines: 10,
linesPerMillisecond: 10,
skills: {
Bash: 5,
Javascript: 2,
},
items: [
{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg',
},
{
id: 2,
name: 'Git',
price: 100,
linesPerMillisecond: 1.2,
icon: '/some/icon/path.svg',
},
{
id: 3,
name: 'Javascript',
price: 10000,
linesPerMillisecond: 14.0,
icon: '/some/icon/path.svg',
},
],
}
expect(gameReducer(state, action)).toEqual(expectedState)
})
it('should handle fetchedItems action', () => {
const state = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: [],
}
const action = {
type: 'game/fetchedItems',
payload: [
{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg',
},
{
id: 2,
name: 'Git',
price: 100,
linesPerMillisecond: 1.2,
icon: '/some/icon/path.svg',
},
{
id: 3,
name: 'Javascript',
price: 10000,
linesPerMillisecond: 14.0,
icon: '/some/icon/path.svg',
},
],
}
const expectedState = {
lines: 6,
linesPerMillisecond: 6,
skills: {},
items: [
{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
icon: '/some/icon/path.svg',
},
{
id: 2,
name: 'Git',
price: 100,
linesPerMillisecond: 1.2,
icon: '/some/icon/path.svg',
},
{
id: 3,
name: 'Javascript',
price: 10000,
linesPerMillisecond: 14.0,
icon: '/some/icon/path.svg',
},
],
}
expect(gameReducer(state, action)).toEqual(expectedState)
})
})

Practical Work
Correction utils/getItemIcon.ts
import BashIcon from 'devicon/icons/bash/bash-original.svg'
import GitIcon from 'devicon/icons/git/git-original.svg'
import JavascriptIcon from 'devicon/icons/javascript/javascript-original.svg'
import ReactIcon from 'devicon/icons/react/react-original.svg'
import VimIcon from 'devicon/icons/vim/vim-original.svg'
import IEIcon from 'devicon/icons/ie10/ie10-original.svg'
import { Item } from '@/types'
type ItemName = keyof typeof iconMap
const iconMap = {
Bash: BashIcon,
Git: GitIcon,
Javascript: JavascriptIcon,
React: ReactIcon,
Vim: VimIcon,
}
export default (item: Item) => {
return iconMap[item.name as ItemName] ?? IEIcon
}

Practical Work
Correction utils/getItemIcon.test.ts
import BashIcon from 'devicon/icons/bash/bash-original.svg'
import IEIcon from 'devicon/icons/ie10/ie10-original.svg'
import getItemIcon from '../getItemIcon'
describe('getItemIcon', () => {
it('provides icon for a known item', () => {
const item = {
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.5,
}
expect(getItemIcon(item)).toBe(BashIcon)
})
it('provides default icon for unknown item', () => {
const item = {
id: 1,
name: 'Unknown item',
price: 10,
linesPerMillisecond: 0.5,
}
expect(getItemIcon(item)).toBe(IEIcon)
})
})

Practical Work
Correction <Section />
import { RootState } from '@/store'
import getItemIcon from '@/utils/getItemIcon'
import { Box, Grid2 as Grid, Typography } from '@mui/material'
import { useSelector } from 'react-redux'
type Props = {
itemName: string
number: number
}
export const Section = ({ itemName, number }: Props) => {
const items = useSelector((state: RootState) => state.game.items)
const item = items.find(element => element.name === itemName)
if (item == null) {
return null
}
return (
<Box component="section">
<Typography variant="subtitle2" marginBottom={1}>{item.name}</Typography>
<Grid container component="ul" gap={1}>
{Array.from({ length: number }).map((_, index) => (
<li
key={index}
>
<img
src={getItemIcon(item)}
alt={item.name}
style={{
width: '2rem',
}}
/>
</li>
))}
</Grid>
</Box>
)
}

Practical Work
Correction <Section />
- Test
import { render, screen } from '@/test-setup'
import { Section } from '../Section'
describe('Section', () => {
it('displays the owned skills', () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: {},
items: [{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.1,
}],
},
}
render(<Section itemName="Bash" number={3} />, { preloadedState: initialState })
expect(screen.getByText('Bash')).toBeInTheDocument()
expect(screen.getAllByAltText('Bash')).toHaveLength(3)
})
it('render anything on unknown skill', () => {
render(<Section itemName="Unknown" number={3} />)
expect(screen.queryByText('Unknown')).not.toBeInTheDocument()
})
})

Practical Work
Correction <Store />
import { Item as ItemType } from '@/types'
import { Item } from './Item.tsx'
import { Grid2 as Grid } from '@mui/material'
import { buyItem } from '@/modules/game.ts'
import { RootState } from '@/store.ts'
import { useSelector, useDispatch } from 'react-redux'
export function Store() {
const lines = useSelector((state: RootState) => state.game.lines)
const items = useSelector((state: RootState) => state.game.items)
const dispatch = useDispatch()
const handleBuy = (item: ItemType) => dispatch(buyItem(item))
return (
<Grid container component="ul" spacing={2} display="flex" flexDirection="column">
{items.map((item, key) => (
<Item
key={key}
item={item}
lines={lines}
onBuy={handleBuy}
/>
))}
</Grid>
)
}

Practical Work
Correction <Store />
- Test
import { render, screen } from '@/test-setup'
import { Store } from '../Store'
import { items } from '@/utils/__mocks__/items.mock'
describe('Store', () => {
it('renders correctly', () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: {},
items,
},
}
render(<Store />, { preloadedState: initialState })
expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/Git/i)).toBeInTheDocument()
expect(screen.getByText(/Javascript/i)).toBeInTheDocument()
expect(screen.getByText(/React/i)).toBeInTheDocument()
expect(screen.getByText(/Vim/i)).toBeInTheDocument()
})
})

Practical Work
Correction of utils file addition items.mock.ts
// src/utils/__mocks__/items.mock
import { Item } from '@/types'
export const items: Item[] = [
{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.1,
},
{
id: 2,
name: 'Git',
price: 100,
linesPerMillisecond: 1.2,
},
{
id: 3,
name: 'Javascript',
price: 10000,
linesPerMillisecond: 14.0,
},
{
id: 4,
name: 'React',
price: 50000,
linesPerMillisecond: 75.0,
},
{
id: 5,
name: 'Vim',
price: 999999,
linesPerMillisecond: 10000.0,
},
]

Practical Work
Correction <Item />
import '@/styles/game/store/item.css'
import { Typography, Button } from '@mui/material'
import { Item as ItemType } from '@/types'
import getItemIcon from '@/utils/getItemIcon'
type Props = {
item: ItemType
lines: number
onBuy: (item: ItemType) => void
}
export function Item({ item, lines, onBuy }: Props) {
const canBuy = lines >= item.price
const linePerSecond = Math.ceil(item.linesPerMillisecond * 10)
return (
<li
className="item"
onClick={() => canBuy && onBuy(item)}
>
<div className="title">
<img src={getItemIcon(item)} alt={item.name} />
<div>
<Typography variant="subtitle1">{item.name}</Typography>
<small>{linePerSecond} lines per second</small>
</div>
</div>
<Button
variant="contained"
color="secondary"
disabled={!canBuy}
>
{item.price}
</Button>
</li>
)
}

Practical Work
Correction <Item />
- Test
import { render, screen, fireEvent } from '@testing-library/react'
import { Item } from '../Item'
import { items } from '@/utils/__mocks__/items.mock'
describe('Item', () => {
it('Renders a buyable item', () => {
const onBuy = vi.fn()
render(
<Item
item={items[0]}
lines={150}
onBuy={onBuy}
/>,
)
expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/1 lines per second/i)).toBeInTheDocument()
expect(screen.getByRole('button')).not.toBeDisabled()
fireEvent.click(screen.getByRole('button'))
expect(onBuy).toHaveBeenCalledWith(items[0])
})
it('Renders a non buyable item', () => {
const onBuy = vi.fn()
render(
<Item
item={items[0]}
lines={0}
onBuy={onBuy}
/>,
)
expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/1 lines per second/i)).toBeInTheDocument()
expect(screen.getByRole('button')).toBeDisabled()
fireEvent.click(screen.getByRole('button'))
expect(onBuy).not.toHaveBeenCalledWith(items[0])
})
})

Practical Work
Correction <Score />
- Test
import { Score } from '../Score'
import { render, screen } from '@/test-setup'
describe('Score', () => {
it('should display the number of lines', () => {
const initialState = {
game: { lines: 6, linesPerMillisecond: 2, skills: {}, items: [] },
}
render(<Score />, { preloadedState: initialState })
expect(screen.getByText(/6 lines/i)).toBeInTheDocument()
})
})

Practical Work
Correction <Game />
- Test
import { Game } from '../Game'
import { render, screen } from '@/test-setup'
describe('Game', () => {
it('renders correctly', async () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: {},
items: [{
id: 1,
name: 'Bash',
price: 10,
linesPerMillisecond: 0.1,
}],
},
}
render(<Game />, { preloadedState: initialState })
expect(screen.getByText(/6 lines/)).toBeInTheDocument()
expect(screen.getByText(/per second: 20/)).toBeInTheDocument()
expect(screen.getByText(/Skills/)).toBeInTheDocument()
expect(screen.getByText(/Store/)).toBeInTheDocument()
expect(screen.getByText(/Bash/)).toBeInTheDocument()
await screen.findByText(/8 lines/, undefined, { timeout: 150 })
await screen.findByText(/10 lines/, undefined, { timeout: 150 })
})
})

Practical Work
Correction <Skills />
- Test
import { render, screen } from '@/test-setup'
import { Skills } from '../Skills'
import { items } from '@/utils/__mocks__/items.mock'
describe('Skills', () => {
it('renders correctly', () => {
const initialState = {
game: {
lines: 6,
linesPerMillisecond: 2,
skills: { Bash: 2, Git: 3, Javascript: 4 },
items,
},
}
render(<Skills />, { preloadedState: initialState })
expect(screen.getByText(/Bash/i)).toBeInTheDocument()
expect(screen.getByText(/Git/i)).toBeInTheDocument()
expect(screen.getByText(/Javascript/i)).toBeInTheDocument()
})
})

Practical Work
Correction <GitClicker
/>
- Test
import { render } from '@/test-setup'
import GitClicker from '../GitClicker'
import { items } from '@/utils/__mocks__/items.mock'
describe('GitClicker page', () => {
it('renders correctly', () => {
const initialState = {
game: {
lines: 0,
linesPerMillisecond: 0,
skills: {},
items,
},
}
const { asFragment } = render(<GitClicker />, { preloadedState: initialState })
expect(asFragment()).toMatchSnapshot()
})
})

Practical Work
Make sure you have deleted the items.ts
file
11
Forms
Handle forms, validation and data submission


Forms
Let's move on to a more advanced step
Checkout the demo project

Run the following command to fetch the demo project at the required step.
git clone --depth 1 --branch v0.9 https://github.com/KnpLabs/training-react-gitclicker.git

cd training-react-gitclicker
pnpm install
pnpm start
Install and run
mettre la bonne branch
form starter
mettre les bonnes instructions
Forms
What's behind the hood
Added a new route /rules

This routes will allows to manage the available items in the store.
Created a modules/rules.ts
A new dedicated module has been added to handle the game rules.
List of available items in <ItemsList />
A table showing available items has been created.
Forms - example

import { InputLabel, Input, Button } from '@mui/material'
import { useState } from 'react'
type FormValues = {
firstName: string
lastName: string
}
export function MyForm() {
const [formValues, setFormValues] = useState<FormValues>({
firstName: '',
lastName: '',
})
const handleChange: React.ComponentProps<'input'>['onChange'] = (e) => {
const { name, value } = e.target
setFormValues({
...formValues,
[name]: value,
})
}
const handleSubmit = () => {
// ...
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<InputLabel htmlFor="firstName">First Name</InputLabel>
<Input
id="firstName"
type="text"
name="firstName"
value={formValues.firstName}
onChange={handleChange}
/>
<InputLabel htmlFor="lastName">Last Name</InputLabel>
<Input
id="lastName"
type="text"
name="lastName"
value={formValues.lastName}
onChange={handleChange}
/>
<Button
type="submit"
variant="contained"
color="primary"
>
Add Person
</Button>
</form>
)
}
Create a type for your form data
Create an handleChange
method to mutate your state on user inputs
Create a state to store the data
Create handleSubmit
method to handle form submission
Practical Work

Practical work
<CreateItemForm />
form component

-
Create the
<CreateItemForm />
component- You can use the MUI
<Input />
and<InputLabel />
components
- You can use the MUI
- Send data to the API using a redux thunk
- Populate the redux store with the new item
TODO

Practical work
Correction - modules/rules.ts

import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { Item } from '@/types'
// Initial state
type RulesState = {
items: Item[]
}
const INITIAL_STATE: RulesState = {
items: [],
}
// Side Effects / thunks
export const fetchItems = createAsyncThunk(
'rules/fetchItems',
async (_, { dispatch }) => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items`)
const items = await response.json() as Item[]
dispatch(fetchedItems(items))
},
)
export const addItem = createAsyncThunk(
'rules/addItem',
async (itemData: Omit<Item, 'id'>, { dispatch }) => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
})
const newItem = await response.json() as Item
dispatch(itemReceived(newItem))
},
)
const rules = createSlice({
name: 'rule',
initialState: INITIAL_STATE,
reducers: {
fetchedItems: (state, action: PayloadAction<Item[]>) => {
state.items = action.payload
},
itemReceived: (state, action: PayloadAction<Item>) => {
state.items.push(action.payload)
},
},
})
const {
fetchedItems,
itemReceived,
} = rules.actions
export {
fetchedItems,
}
export default rules.reducer
Practical work
Correction - <CreateItemForm />

import '@/styles/rules/form.css'
import { useState } from 'react'
import { useAppDispatch } from '@/store'
import { addItem } from '@/modules/rules'
import { InputLabel, Input, Button } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
type FormValues = {
name: string
price: number
linesPerMillisecond: number
}
export function CreateItemForm() {
const dispatch = useAppDispatch()
const [formValues, setFormValues] = useState<FormValues>({
name: '',
price: 0,
linesPerMillisecond: 0,
})
const handleChange: React.ComponentProps<'input'>['onChange'] = (e) => {
const { name, value } = e.target
setFormValues({
...formValues,
[name]: value,
})
}
const handleSubmit: React.ComponentProps<'form'>['onSubmit'] = (e) => {
e.preventDefault()
dispatch(addItem(formValues))
}
return (
<form onSubmit={handleSubmit} className="form">
<div>
<InputLabel htmlFor="name">Name *</InputLabel>
<Input
id="name"
className="input"
type="text"
name="name"
placeholder="Item Name"
value={formValues.name}
onChange={handleChange}
/>
</div>
<div>
<InputLabel htmlFor="price">Price *</InputLabel>
<Input
id="price"
className="input"
type="number"
name="price"
value={formValues.price}
onChange={handleChange}
/>
</div>
<div>
<InputLabel htmlFor="linesPerMillisecond">Lines per millisecond *</InputLabel>
<Input
id="linesPerMillisecond"
className="input"
type="number"
name="linesPerMillisecond"
inputProps={{
step: 0.1,
}}
value={formValues.linesPerMillisecond}
onChange={handleChange}
/>
</div>
<Button
type="submit"
variant="contained"
startIcon={<AddIcon />}
color="primary"
>
Add Item
</Button>
</form>
)
}
Practical work
Correction - styles/rules/form.css

.form {
display: flex;
flex-direction: column;
gap: 1rem;
.input {
width: 100%;
padding: 0.5rem;
margin: 0.25rem 0;
border: 1px solid #ccc;
border-radius: 0.25rem;
box-sizing: border-box;
background-color: white;
}
button {
margin-inline: auto;
}
}
Practical Work

Practical work
<CreateItemForm />
form component

- Add form data validation
- Display error messages on invalid fields
TODO

Practical work
Correction <CreateItemForm />

import '@/styles/rules/form.css'
import { useState } from 'react'
import { useAppDispatch } from '@/store'
import { addItem } from '@/modules/rules'
import { InputLabel, Input, Button } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
type FormValues = {
name: string
price: number
linesPerMillisecond: number
}
type FormErrors = {
name?: string
price?: string
linesPerMillisecond?: string
}
export function CreateItemForm() {
const dispatch = useAppDispatch()
const [formValues, setFormValues] = useState<FormValues>({
name: '',
price: 0,
linesPerMillisecond: 0,
})
const [formErrors, setFormErrors] = useState<FormErrors>({})
const handleChange: React.ComponentProps<'input'>['onChange'] = (e) => {
const { name, value } = e.target
setFormValues({
...formValues,
[name]: value,
})
}
const validateForm = () => {
const errors: FormErrors = {}
if (!formValues.name) {
errors.name = 'Name is required'
}
if (!formValues.price) {
errors.price = 'Price is required'
}
if (formValues.price < 0) {
errors.price = 'Price must be a positive number'
}
if (!formValues.linesPerMillisecond) {
errors.linesPerMillisecond = 'Lines per millisecond is required'
}
if (formValues.linesPerMillisecond <= 0) {
errors.linesPerMillisecond = 'Lines per millisecond must be greater than 0'
}
setFormErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit: React.ComponentProps<'form'>['onSubmit'] = (e) => {
e.preventDefault()
if (!validateForm()) {
return
}
dispatch(addItem(formValues))
}
return (
<form onSubmit={handleSubmit} className="form">
<div>
<InputLabel htmlFor="name">Name *</InputLabel>
<Input
id="name"
className="input"
type="text"
name="name"
placeholder="Item Name"
value={formValues.name}
error={formErrors.name != null}
onChange={handleChange}
/>
{formErrors.name && (
<p className="error-message">
{formErrors.name}
</p>
)}
</div>
<div>
<InputLabel htmlFor="price">Price *</InputLabel>
<Input
id="price"
className="input"
type="number"
name="price"
value={formValues.price}
error={formErrors.price != null}
onChange={handleChange}
/>
{formErrors.price && (
<p className="error-message">
{formErrors.price}
</p>
)}
</div>
<div>
<InputLabel htmlFor="linesPerMillisecond">Lines per millisecond *</InputLabel>
<Input
id="linesPerMillisecond"
className="input"
type="number"
name="linesPerMillisecond"
inputProps={{
step: 0.1,
}}
value={formValues.linesPerMillisecond}
error={formErrors.linesPerMillisecond != null}
onChange={handleChange}
/>
{formErrors.linesPerMillisecond && (
<p className="error-message">
{formErrors.linesPerMillisecond}
</p>
)}
</div>
<Button
type="submit"
variant="contained"
startIcon={<AddIcon />}
color="primary"
>
Add Item
</Button>
</form>
)
}
Practical work
Correction styles/rules/form.css

.form {
display: flex;
flex-direction: column;
gap: 1rem;
.input {
width: 100%;
padding: 0.5rem;
margin: 0.25rem 0;
border: 1px solid #ccc;
border-radius: 0.25rem;
box-sizing: border-box;
background-color: white;
}
button {
margin-inline: auto;
}
}
.error-message {
color: red;
font-size: 0.875rem;
margin-top: 0.25rem;
}
Practical Work

Practical work
<CreateItemForm />
form component

- Redirect user to the items list on successful item creation
- Disable submit button on request loading
TODO - Handle request states

- Store and update the state of the request in your
rules.ts
module
- Observe the request state value in your component
- Redirect user on success
- Disable submit button on load
Practical work
Correction types.ts

export type Item = {
id: number
name: string
price: number
linesPerMillisecond: number
}
export type OwnedItems = {
[key: string]: number
}
export const RequestStatus = {
Idle: 'idle',
Loading: 'loading',
Succeeded: 'succeeded',
Failed: 'failed',
} as const
export type TRequestStatus = typeof RequestStatus[keyof typeof RequestStatus]
Practical work
Correction modules/rules.ts

import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { RequestStatus, type Item, type TRequestStatus } from '@/types'
// Initial state
type RulesState = {
items: Item[]
addItemRequestStatus: TRequestStatus
}
const INITIAL_STATE: RulesState = {
items: [],
addItemRequestStatus: RequestStatus.Idle,
}
// Side Effects / thunks
export const fetchItems = createAsyncThunk(
'rules/fetchItems',
async (_, { dispatch }) => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items`)
const items = await response.json() as Item[]
dispatch(fetchedItems(items))
},
)
export const addItem = createAsyncThunk(
'rules/addItem',
async (itemData: Omit<Item, 'id'>, { dispatch }) => {
dispatch(setAddItemRequestStatus(RequestStatus.Loading))
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
})
const newItem = await response.json() as Item
dispatch(itemReceived(newItem))
dispatch(setAddItemRequestStatus(RequestStatus.Succeeded))
},
)
const rules = createSlice({
name: 'rule',
initialState: INITIAL_STATE,
reducers: {
fetchedItems: (state, action: PayloadAction<Item[]>) => {
state.items = action.payload
},
itemReceived: (state, action: PayloadAction<Item>) => {
state.items.push(action.payload)
},
setAddItemRequestStatus: (state, action: PayloadAction<TRequestStatus>) => {
state.addItemRequestStatus = action.payload
},
},
})
const {
fetchedItems,
itemReceived,
setAddItemRequestStatus,
} = rules.actions
export {
fetchedItems,
setAddItemRequestStatus,
}
export default rules.reducer
Practical work
Correction <CreateItemForm />

import '@/styles/rules/form.css'
import { useEffect, useState } from 'react'
import { RootState, useAppDispatch } from '@/store'
import { addItem, setAddItemRequestStatus } from '@/modules/rules'
import { InputLabel, Input, Button } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router'
import { RequestStatus } from '@/types'
type FormValues = {
name: string
price: number
linesPerMillisecond: number
}
type FormErrors = {
name?: string
price?: string
linesPerMillisecond?: string
}
export function CreateItemForm() {
const dispatch = useAppDispatch()
const navigate = useNavigate()
const requestStatus = useSelector((state: RootState) => state.rules.addItemRequestStatus)
useEffect(() => {
if (requestStatus === RequestStatus.Succeeded) {
dispatch(setAddItemRequestStatus(RequestStatus.Idle))
navigate('/rules')
}
}, [requestStatus, navigate, dispatch])
const [formValues, setFormValues] = useState<FormValues>({
name: '',
price: 0,
linesPerMillisecond: 0,
})
const [formErrors, setFormErrors] = useState<FormErrors>({})
const handleChange: React.ComponentProps<'input'>['onChange'] = (e) => {
const { name, value } = e.target
setFormValues({
...formValues,
[name]: value,
})
}
const validateForm = () => {
const errors: FormErrors = {}
if (!formValues.name) {
errors.name = 'Name is required'
}
if (!formValues.price) {
errors.price = 'Price is required'
}
if (formValues.price < 0) {
errors.price = 'Price must be a positive number'
}
if (!formValues.linesPerMillisecond) {
errors.linesPerMillisecond = 'Lines per millisecond is required'
}
if (formValues.linesPerMillisecond <= 0) {
errors.linesPerMillisecond = 'Lines per millisecond must be greater than 0'
}
setFormErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit: React.ComponentProps<'form'>['onSubmit'] = (e) => {
e.preventDefault()
if (!validateForm()) {
return
}
dispatch(addItem(formValues))
}
return (
<form onSubmit={handleSubmit} className="form">
<div>
<InputLabel htmlFor="name">Name *</InputLabel>
<Input
id="name"
className="input"
type="text"
name="name"
placeholder="Item Name"
value={formValues.name}
error={formErrors.name != null}
onChange={handleChange}
/>
{formErrors.name && (
<p className="error-message">
{formErrors.name}
</p>
)}
</div>
<div>
<InputLabel htmlFor="price">Price *</InputLabel>
<Input
id="price"
className="input"
type="number"
name="price"
value={formValues.price}
error={formErrors.price != null}
onChange={handleChange}
/>
{formErrors.price && (
<p className="error-message">
{formErrors.price}
</p>
)}
</div>
<div>
<InputLabel htmlFor="linesPerMillisecond">Lines per millisecond *</InputLabel>
<Input
id="linesPerMillisecond"
className="input"
type="number"
name="linesPerMillisecond"
inputProps={{
step: 0.1,
}}
value={formValues.linesPerMillisecond}
error={formErrors.linesPerMillisecond != null}
onChange={handleChange}
/>
{formErrors.linesPerMillisecond && (
<p className="error-message">
{formErrors.linesPerMillisecond}
</p>
)}
</div>
<Button
type="submit"
variant="contained"
startIcon={<AddIcon />}
color="primary"
>
Add Item
</Button>
</form>
)
}
Practical Work

Practical work
<EditItemForm />
form component

- Validate the form data
- Send data the API
- Populate the redux store with the edited item
- Redirect to the list
TODO
Practical work
Correction - modules/rules.ts

import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { RequestStatus, type Item, type TRequestStatus } from '@/types'
// Initial state
type RulesState = {
items: Item[]
addItemRequestStatus: TRequestStatus
editItemRequestStatus: TRequestStatus
}
const INITIAL_STATE: RulesState = {
items: [],
addItemRequestStatus: RequestStatus.Idle,
editItemRequestStatus: RequestStatus.Idle,
}
// Side Effects / thunks
export const fetchItems = createAsyncThunk(
'rules/fetchItems',
async (_, { dispatch }) => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items`)
const items = await response.json() as Item[]
dispatch(fetchedItems(items))
},
)
export const addItem = createAsyncThunk(
'rules/addItem',
async (itemData: Omit<Item, 'id'>, { dispatch }) => {
dispatch(setAddItemRequestStatus(RequestStatus.Loading))
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
})
const newItem = await response.json() as Item
dispatch(itemReceived(newItem))
dispatch(setAddItemRequestStatus(RequestStatus.Succeeded))
},
)
export const editItem = createAsyncThunk(
'rules/editItem',
async (itemData: Item, { dispatch }) => {
dispatch(setEditItemRequestStatus(RequestStatus.Loading))
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items/${itemData.id}`, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
})
const updatedItem = await response.json() as Item
dispatch(itemUpdated(updatedItem))
dispatch(setEditItemRequestStatus(RequestStatus.Succeeded))
},
)
const rules = createSlice({
name: 'rule',
initialState: INITIAL_STATE,
reducers: {
fetchedItems: (state, action: PayloadAction<Item[]>) => {
state.items = action.payload
},
itemReceived: (state, action: PayloadAction<Item>) => {
state.items.push(action.payload)
},
itemUpdated: (state, action: PayloadAction<Item>) => {
const index = state.items.findIndex(item => item.id === action.payload.id)
if (index !== -1) {
state.items[index] = action.payload
}
},
setAddItemRequestStatus: (state, action: PayloadAction<TRequestStatus>) => {
state.addItemRequestStatus = action.payload
},
setEditItemRequestStatus: (state, action: PayloadAction<TRequestStatus>) => {
state.editItemRequestStatus = action.payload
},
},
})
const {
fetchedItems,
setAddItemRequestStatus,
setEditItemRequestStatus,
itemReceived,
itemUpdated,
} = rules.actions
export {
fetchedItems,
setAddItemRequestStatus,
setEditItemRequestStatus,
}
export default rules.reducer
Practical work
Correction - <EditItemForm />

import '@/styles/rules/form.css'
import { useEffect, useState } from 'react'
import { RootState, useAppDispatch } from '@/store'
import { InputLabel, Input, Button } from '@mui/material'
import SaveIcon from '@mui/icons-material/Save'
import { useSelector } from 'react-redux'
import { useNavigate, useParams } from 'react-router'
import { RequestStatus } from '@/types'
import { setEditItemRequestStatus, editItem } from '@/modules/rules'
type FormValues = {
id: number
name: string
price: number
linesPerMillisecond: number
}
type FormErrors = {
name?: string
price?: string
linesPerMillisecond?: string
}
export function EditItemForm() {
const dispatch = useAppDispatch()
const navigate = useNavigate()
const { id: itemId } = useParams()
const item = useSelector((state: RootState) => {
if (itemId == null) {
return null
}
return state.rules.items.find(item => item.id === Number.parseInt(itemId))
})
const requestStatus = useSelector((state: RootState) => state.rules.editItemRequestStatus)
useEffect(() => {
if (requestStatus === RequestStatus.Succeeded) {
dispatch(setEditItemRequestStatus(RequestStatus.Idle))
navigate('/rules')
}
}, [requestStatus, navigate, dispatch])
const [formValues, setFormValues] = useState<FormValues>({
id: item?.id ?? 0,
name: item?.name ?? '',
price: item?.price ?? 0,
linesPerMillisecond: item?.linesPerMillisecond ?? 0,
})
const [formErrors, setFormErrors] = useState<FormErrors>({})
const handleChange: React.ComponentProps<'input'>['onChange'] = (e) => {
const { name, value } = e.target
setFormValues({
...formValues,
[name]: value,
})
}
const validateForm = () => {
const errors: FormErrors = {}
if (!formValues.name) {
errors.name = 'Name is required'
}
if (!formValues.price) {
errors.price = 'Price is required'
}
if (formValues.price < 0) {
errors.price = 'Price must be a positive number'
}
if (!formValues.linesPerMillisecond) {
errors.linesPerMillisecond = 'Lines per millisecond is required'
}
if (formValues.linesPerMillisecond <= 0) {
errors.linesPerMillisecond = 'Lines per millisecond must be greater than 0'
}
setFormErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit: React.ComponentProps<'form'>['onSubmit'] = (e) => {
e.preventDefault()
if (!validateForm()) {
return
}
dispatch(editItem(formValues))
}
if (item == null) {
return <p className="error-message">Item with id: {itemId} not found</p>
}
return (
<form onSubmit={handleSubmit} className="form">
<div>
<InputLabel htmlFor="name">Name *</InputLabel>
<Input
id="name"
className="input"
type="text"
name="name"
placeholder="Item Name"
value={formValues.name}
error={formErrors.name != null}
onChange={handleChange}
/>
{formErrors.name && (
<p className="error-message">
{formErrors.name}
</p>
)}
</div>
<div>
<InputLabel htmlFor="price">Price *</InputLabel>
<Input
id="price"
className="input"
type="number"
name="price"
value={formValues.price}
error={formErrors.price != null}
onChange={handleChange}
/>
{formErrors.price && (
<p className="error-message">
{formErrors.price}
</p>
)}
</div>
<div>
<InputLabel htmlFor="linesPerMillisecond">Lines per millisecond *</InputLabel>
<Input
id="linesPerMillisecond"
className="input"
type="number"
name="linesPerMillisecond"
inputProps={{
step: 0.1,
}}
value={formValues.linesPerMillisecond}
error={formErrors.linesPerMillisecond != null}
onChange={handleChange}
/>
{formErrors.linesPerMillisecond && (
<p className="error-message">
{formErrors.linesPerMillisecond}
</p>
)}
</div>
<Button
type="submit"
variant="contained"
disabled={requestStatus === RequestStatus.Loading}
startIcon={<SaveIcon />}
color="primary"
>
Save
</Button>
</form>
)
}
Practical Work

Practical work
Delete an item

Handle click on the delete button
- Send the delete request to the API
- Remove the deleted item from the store

Practical work
Correction - modules/rules.ts

import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { RequestStatus, type Item, type TRequestStatus } from '@/types'
// Initial state
type RulesState = {
items: Item[]
addItemRequestStatus: TRequestStatus
editItemRequestStatus: TRequestStatus
deleteItemRequestStatus: TRequestStatus
}
const INITIAL_STATE: RulesState = {
items: [],
addItemRequestStatus: RequestStatus.Idle,
editItemRequestStatus: RequestStatus.Idle,
deleteItemRequestStatus: RequestStatus.Idle,
}
// Side Effects / thunks
export const fetchItems = createAsyncThunk(
'rules/fetchItems',
async (_, { dispatch }) => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items`)
const items = await response.json() as Item[]
dispatch(fetchedItems(items))
},
)
export const addItem = createAsyncThunk(
'rules/addItem',
async (itemData: Omit<Item, 'id'>, { dispatch }) => {
dispatch(setAddItemRequestStatus(RequestStatus.Loading))
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
})
const newItem = await response.json() as Item
dispatch(itemReceived(newItem))
dispatch(setAddItemRequestStatus(RequestStatus.Succeeded))
},
)
export const editItem = createAsyncThunk(
'rules/editItem',
async (itemData: Item, { dispatch }) => {
dispatch(setEditItemRequestStatus(RequestStatus.Loading))
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items/${itemData.id}`, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
})
const updatedItem = await response.json() as Item
dispatch(itemUpdated(updatedItem))
dispatch(setEditItemRequestStatus(RequestStatus.Succeeded))
},
)
export const deleteItem = createAsyncThunk(
'rules/deleteItem',
async (itemId: number, { dispatch }) => {
await fetch(`${import.meta.env.VITE_API_URL}/api/shop/items/${itemId}`, {
method: 'DELETE',
})
dispatch(itemDeleted(itemId))
},
)
const rules = createSlice({
name: 'rule',
initialState: INITIAL_STATE,
reducers: {
fetchedItems: (state, action: PayloadAction<Item[]>) => {
state.items = action.payload
},
itemReceived: (state, action: PayloadAction<Item>) => {
state.items.push(action.payload)
},
itemUpdated: (state, action: PayloadAction<Item>) => {
const index = state.items.findIndex(item => item.id === action.payload.id)
if (index !== -1) {
state.items[index] = action.payload
}
},
itemDeleted: (state, action: PayloadAction<number>) => {
state.items = state.items.filter(item => item.id !== action.payload)
},
setAddItemRequestStatus: (state, action: PayloadAction<TRequestStatus>) => {
state.addItemRequestStatus = action.payload
},
setEditItemRequestStatus: (state, action: PayloadAction<TRequestStatus>) => {
state.editItemRequestStatus = action.payload
},
setDeleteItemRequestStatus: (state, action: PayloadAction<TRequestStatus>) => {
state.deleteItemRequestStatus = action.payload
},
},
})
const {
fetchedItems,
setAddItemRequestStatus,
setEditItemRequestStatus,
itemReceived,
itemUpdated,
itemDeleted,
} = rules.actions
export {
fetchedItems,
setAddItemRequestStatus,
setEditItemRequestStatus,
}
export default rules.reducer
Practical work
Correction - <ItemsList />

import { RootState, useAppDispatch } from '@/store'
import numberFormat from '@/tests/numberFormat'
import { TableContainer, Paper, Table, TableHead, TableRow, TableCell, TableBody, IconButton, Fab } from '@mui/material'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import AddIcon from '@mui/icons-material/Add'
import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router'
import { deleteItem } from '@/modules/rules'
import { Item, RequestStatus } from '@/types'
export function ItemsList() {
const dispatch = useAppDispatch()
const navigate = useNavigate()
const items = useSelector((state: RootState) => state.rules.items)
const requestStatus = useSelector((state: RootState) => state.rules.deleteItemRequestStatus)
const handleDelete = (item: Item) => {
dispatch(deleteItem(item.id))
}
return (
<>
<TableContainer component={Paper}>
<Table aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell align="right">Lines per seconds</TableCell>
<TableCell align="right">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map(item => (
<TableRow key={item.id}>
<TableCell component="th" scope="row">{item.name}</TableCell>
<TableCell align="right">{numberFormat(item.price)}</TableCell>
<TableCell align="right">{numberFormat(item.linesPerMillisecond * 10)}</TableCell>
<TableCell align="right" sx={{ display: 'flex', gap: 1 }}>
<IconButton
onClick={() => navigate(`/rules/edit/${item.id}`)}
aria-label="edit"
>
<EditIcon />
</IconButton>
<IconButton
color="error"
aria-label="delete"
disabled={requestStatus === RequestStatus.Loading}
onClick={() => handleDelete(item)}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Fab
onClick={() => navigate('/rules/add')}
color="primary"
aria-label="add"
sx={{
position: 'fixed',
bottom: 16,
right: 16,
}}
>
<AddIcon />
</Fab>
</>
)
}


React - TypeScript 2025
By KNP Labs