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

Getting started

Create new app using Vite

Prerequisites

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 the tsconfig.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 like loader, action, and useFetcher. 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

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
  • 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

React - TypeScript 2025