📚 Pour commencer

Pour avoir un peu plus de contexte sur les différents sujets dont nous allons parler dans cet article - voici quelques resources très sympathiques qui vont vous permettre à d'y voir plus clair 🙂.

🤓 Un mot sur les modèles riches en DDD

Au sein de l'équipe tech de Gojob, nous nous efforçons de fournir une valeur maximale à nos utilisateurs - sous forme de code bien testé, performant et de qualité que nous concevons en utilisant des techniques et principes basés sur le Domain Driven Design pour mieux représenter la réalité de notre cœur métier.

L'une des caractéristiques du DDD est la notion de Modèles Riches, ce qui signifie qu'ils :

  • 🫡 Sont encapsulés et suivent les principes du masquage de l'information.
  • ✨ Peuvent garantir leur validité à tout moment.
  • 👷 Contiennent la logique métier.

🧑‍💻 L'immutabilité dans React

L'un des principes fondamentaux de React est l'utilisation de l'immutabilité. Cette approche consiste à considérer les données comme immuables, c'est-à-dire incapables d'être modifiées une fois qu'elles ont été créées.

Bien que cela puisse sembler contre-intuitif au premier abord, l'immutabilité joue un rôle crucial dans la création d'applications React rapides, robustes et prévisibles.

Ce concept permet, entre autres, à React de détecter les changements dans les données et de mettre à jour l'interface utilisateur en conséquence:

let [count, setCount] = useState(0);
 
// déclenche une mise à jour de l'interface utilisateur 👌
setCount(1);
// ne déclenche pas de mise à jour 😱
count = 1;

Cela permet également de simplifier la logique de l'application en évitant les effets de bord et en rendant le code plus facile à lire et à maintenir.

De plus - l'immutabilité permet de faciliter à React la détection des changements dans les données car il suffit de comparer les références des objets pour savoir si quelque chose a changé.

🪄 Un mot sur Immer

Immer (qui signifie "toujours" en allemand) est une petite bibliothèque (3,6 Ko) qui simplifie considérablement la gestion de l'immutabilité.

Donc, au lieu de faire ce qui suit :

// Définir l'état initial
const state = [
  { title: 'Acheter des courses', done: false },
  { title: 'Conquérir le monde', done: false },
];
 
const completeTodo = (index: number) => {
  // Vérifier les limites
  if (index < 0 || index >= state.length) {
    return state;
  }
 
  // ୧(ಠ益ಠ)୨
  return [
    ...state.slice(0, index), // tronquer jusqu'à l'élément
    { ...state[index], done: true }, // remplacer l'élément par déstructuration
    ...state.slice(index + 1), // tronquer depuis l'élément jusqu'à la fin
  ];
};
 
console.assert(state !== completeTodo(1)); // ok !

Vous pouvez simplement faire :

import { produce } from 'immer';
 
const state = [
  { title: 'Acheter des courses', done: false },
  { title: 'Conquérir le monde', done: false },
];
 
const completeTodo = (index: number) => {
  return produce(state, (draft) => {
    // Appliquez toutes vos modifications au brouillon comme vous le souhaitez ᕕ( ಠ‿ಠ)ᕗ
    const item = draft[index];
    if (item) {
      item.done = true;
    }
  });
};
 
console.assert(state !== completeTodo(1)); // ok !

En appliquant un peu de m̷a̴g̴i̷e̴ ̵n̴o̷i̴r̷e̷ ̴i̷n̷t̴e̷r̷d̷i̷t̷e̷, Immer est capable de produire un état immuable comme si on était en train de modifier l'objet 🤯.

Il est également :

⏱️ Un cas d'utilisation réel

Maintenant que nous avons vu de quoi Immer est capable, passons au sujet le plus intéressant - comment pouvons-nous utiliser Immer dans nos use cases frontend avec React.

Nous allons illustrer notre exemple en construisant un Counter - il s'agit d'un cas d'utilisation très simple (et beaucoup trop utilisé 😅), mais ça fera l'affaire pour illustrer les principes de modèles riches en DDD.

Nous aborderons un vrai use case de Gojob plus tard sur cette page.

Pour cet exemple, nous allons construire une application de compteur simple avec les spécifications suivantes :

  • Nous devons afficher la valeur courante du compteur.
  • Nous devons afficher si la valeur du compteur est paire ou impaire.
  • L'utilisateur peut augmenter la valeur du compteur.
  • L'utilisateur peut diminuer la valeur du compteur.
  • La valeur du compteur ne peut pas être négative.

Commençons par définir notre modèle de Counter comme nous le ferions habituellement de manière agnostique par rapport au framework, et en utilisant une approche mutable.

import { immerable } from 'immer';
 
export class Counter {
  // Ceci est nécessaire pour que Immer fonctionne avec les instances de classe
  // https://immerjs.github.io/immer/complex-objects
  public readonly [immerable] = true;
 
  private _value = 0; // l'encapsulation FTW !
 
  get value() {
    return this._value;
  }
  get isEven() {
    return this._value % 2 === 0;
  }
  increment() {
    this._value += 1;
  }
  decrement() {
    if (this._value === 0) {
      throw new RangeError('Counter cannot be negative');
    }
    this._value -= 1;
  }
}
Et la test suite qui va avec (qui est très simple car c'est une classe comme une autre) :
import { Counter } from "./counter";
 
describe("Counter", () => {
  let counter: Counter;
 
  beforeEach(() => {
    counter = new Counter();
  });
 
  it("commence à 0", () => {
    expect(counter.value).toEqual(0);
  });
 
  it("incrémente et décremente la valeur", () => {
    counter.increment(); 
    expect(counter.value).toEqual(1);
 
    counter.decrement();
    expect(counter.value).toEqual(0);
  });
 
  it("indique si la valeur est paire ou impaire", () => {
    expect(counter.isEven).toEqual(true);
 
    counter.increment();
    expect(counter.isEven).toEqual(false);
  });
 
  it("ne peut pas être inférieure à 0", () => {
    expect(() => counter.decrement()).toThrow(RangeError);
  });
});

Nous devons utiliser des bindings spéciales pour React appelées use-immer (0,3 Ko) pour lier notre instance de modèle à React et produire de nouveaux états.

import { Counter } from "./models/counter";
import { useImmer } from "use-immer";
 
export default function App() {
  const [counter, updateCounter] = useImmer(() => new Counter());
 
  const handleIncrement = () => updateCounter((draft) => draft.increment());
  const handleDecrement = () => {
    updateCounter((draft) => {
      try {
        draft.decrement(); // n'oubliez pas de mettre le try/catch à l'intérieur du setter
      } catch (error) {
        console.error(error);
      }
    });
  };
 
  return (
    <main>
      <h1>
        {counter.value}
        <span>({counter.isEven ? "pair" : "impair"})</span>
      </h1>
      <div>
        <button onClick={handleDecrement}>-</button>
        <button onClick={handleIncrement}>+</button>
      </div>
    </main>
  );
}
Avec la suite de tests correspondante qui reflète celle du modèle !
import { render, screen, fireEvent } from "@testing-library/react";
import App from "./App";
 
import "@testing-library/jest-dom/extend-expect";
 
describe("App", () => {
  beforeEach(() => {
    render(<App />);
  });
 
  it("affiche l'état initial", () => {
    expect(screen.getByRole("heading", { name: "0 (pair)" })).toBeVisible();
  });
 
  it("permet d'augmenter et de diminuer le compteur", () => {
    fireEvent.click(screen.getByRole("button", { name: "+" }));
    expect(screen.getByRole("heading", { name: "1 (impair)" })).toBeVisible();
 
    fireEvent.click(screen.getByRole("button", { name: "-" }));
    expect(screen.getByRole("heading", { name: "0 (pair)" })).toBeVisible();
  });
 
  it("ne permet pas de diminuer le compteur en dessous de 0", () => {
    fireEvent.click(screen.getByRole("button", { name: "-" }));
    expect(screen.getByRole("heading", { name: "0 (pair)" })).toBeVisible();
  });
});

Vous voyez, ce n'est vraiment pas très compliqué ! 🎉

Cependant, le compteur lui-même est mutable, donc nous pourrions accidentellement faire ceci 👀 :

const [counter, updateCounter] = useImmer(() => new Counter());
 
<button onClick={() => counter.increment()}>+</button> // oh-oh -> le nouvel état est désynchronisé

🫡 Rendons notre compteur véritablement immuable

Modifions nos méthodes increment et decrement pour produire de nouveaux compteurs.

import { immerable, produce } from "immer";
 
export class Counter {
  public readonly [immerable] = true;
  private _value = 0;
 
  get value() {
    return this._value;
  }
  get isEven() {
    return this.value % 2 === 0;
  }
  increment() {
    return produce(this, (draft) => draft._value += 1);
  }
  decrement() {
    if (this.value === 0) {
      throw new RangeError("Le compteur ne peut pas être négatif");
    }
    return produce(this, (draft) => draft._value -= 1);
  }
  toJSON() { // à des fins de test
    return {
      value: this.value,
      isEven: this.isEven
    };
  }
}
Avec la suite de tests correspondante 👀
import { Counter } from "./counter";
 
describe("Counter", () => {
  let counter: Counter;
 
  beforeEach(() => {
    counter = new Counter();
  });
 
  it("commence à 0", () => {
    expect(counter.toJSON()).toEqual({ value: 0, isEven: true });
  });
 
  it("incrémente et décrémente la valeur du compteur", () => {
    const greaterCounter = counter.increment();
    expect(greaterCounter.toJSON()).toEqual({ value: 1, isEven: false });
    expect(greaterCounter.decrement().toJSON()).toEqual({
      value: 0,
      isEven: true
    });
  });
 
  it("lève une erreur si la valeur est inférieure à 0", () => {
    expect(() => counter.decrement()).toThrow(RangeError);
  });
});

Maintenant que nous avons rendu notre modèle immuable, il est temps d'implémenter du code React.

Ici, nous n'avons même pas besoin de use-immer car nous ne mutons jamais le compteur 🔥

Instancions notre état en utilisant la fonction d'initialisation de useState afin d'éviter de recréer l'instance de classe inutilement.

import { useState } from "react";
import { Counter } from "./models/counter";
 
export default function App() {
  const [counter, setCounter] = useState(() => new Counter());
  const handleIncrement = () => setCounter((counter) => counter.increment());
  const handleDecrement = () => {
    setCounter((counter) => { // n'oubliez pas de mettre le try/catch à l'intérieur du setter
      try {
        return counter.decrement();
      } catch (error) {
        console.error(error);
        return counter;
      }
    });
  };
 
  return (
    <main>
      <h1>
        {counter.value}
        <span>({counter.isEven ? "even" : "odd"})</span>
      </h1>
      <button onClick={handleDecrement}>-</button>
      <button onClick={handleIncrement}>+</button>
    </main>
  );
}

Et bien entendu, la suite de tests de ce composant est exactement la même que la précédente ✅

🦄 Gojob context

Voici la partie vraiment intéressante - un vrai use case de Gojob qui a été implémenté en utilisant les techniques mentionnées précédemment.

La nouvelle fonctionnalité "Relevés d'heures" dans l'application Gojob permet à nos chers intérimaires de consulter leurs relevés d'heures et de demander des modifications avant la paie. La fonctionnalité est assez simple, et voici quelques visuels qui illustrent le processus.

Une première illustration des RH dans l'app
Une deuxième illustration des RH dans l'app
Dernière illustration des RH dans l'app

Définissons donc quelques modèles en fonction de ce que nous voyons et savons de ce processus (légèrement simplifié).

Notre définition des models

Heures travaillées

export class WorkedHours {
  public readonly [immerable] = true;
  public readonly __type = 'WorkedHours';
 
  constructor(public readonly value: number) {}
 
  get hours(): number {
    return Math.floor(this.value);
  }
 
  get minutes(): number {
    return Math.round((this.value - this.hours) * 60);
  }
}
Suite de tests correspondante
describe('WorkedHours', () => {
  it('retourne les heures et les minutes', () => {
    let hours = 8;
    expect(new WorkedHours(hours).hours).toBe(8);
    expect(new WorkedHours(hours).minutes).toBe(0);
 
    hours = 8.5;
    expect(new WorkedHours(hours).hours).toBe(8);
    expect(new WorkedHours(hours).minutes).toBe(30);
 
    hours = 8.01;
    expect(new WorkedHours(hours).hours).toBe(8);
    expect(new WorkedHours(hours).minutes).toBe(1);
 
    hours = 8.99;
    expect(new WorkedHours(hours).hours).toBe(8);
    expect(new WorkedHours(hours).minutes).toBe(59);
  });
});

Feuille de temps quotidienne

export class DailyTimesheet {
  public readonly [immerable] = true;
  public readonly __type = 'DailyTimesheet';
  public readonly date: Date;
  public readonly workedHours: WorkedHours;
 
  constructor({ date, workedHours }: DailyTimesheetArgs) {
    this.date = date;
    this.workedHours = workedHours;
  }
 
  get isRestDay(): boolean {
    return this.workedHours.value === 0;
  }
}
Suite de tests correspondante
describe('DailyTimesheet', () => {
  it("retourne si c'est un jour de repos en fonction des heures travaillées", () => {
    const restDailyTimesheet = new DailyTimesheet({
      date: new Date(),
      workedHours: new WorkedHours(0),
    });
    expect(restDailyTimesheet.isRestDay).toBe(true);
 
    const workedDailyTimesheet = new DailyTimesheet({
      date: new Date(),
      workedHours: new WorkedHours(8),
    });
    expect(workedDailyTimesheet.isRestDay).toBe(false);
  });
});

Feuille de temps hebdomadaire

export class WeeklyTimesheet {
  public readonly [immerable] = true;
  public readonly __type = 'WeeklyTimesheet';
 
  private _days: WeeklyDailyTimesheet; // Tableau de DailyTimesheet de longueur 7
 
  constructor({ days }: WeeklyTimesheetArgs) {
    this._days = days;
  }
 
  get range(): Readonly<TimesheetRange> {
    return { from: days[0].date, to: days[6].date };
  }
 
  get workedHours(): Readonly<WorkedHours> {
    const totalWorkedHours = this.days.reduce((total, day) => total + day.workedHours.value, 0);
    return new WorkedHours(totalWorkedHours);
  }
 
  get days(): Readonly<WeeklyDailyTimesheet> {
    return this._days;
  }
 
  produceWithDailyTimesheet(dailyTimesheet: DailyTimesheet) {
    return produce(this, (draft: WeeklyTimesheet) => {
      for (const [index, day] of draft._days.entries()) {
        if (day.date.getTime() === dailyTimesheet.date.getTime()) {
          draft._days[index] = dailyTimesheet;
        }
      }
    });
  }
}
Suite de tests correspondante
describe('WeeklyTimesheet', () => {
  const weeklyTimesheet = new WeeklyTimesheet({
    id: '1',
    days: [
      new DailyTimesheet({ date: new Date('2020-01-01'), workedHours: new WorkedHours(8) }),
      new DailyTimesheet({ date: new Date('2020-01-02'), workedHours: new WorkedHours(8) }),
      new DailyTimesheet({ date: new Date('2020-01-03'), workedHours: new WorkedHours(8) }),
      new DailyTimesheet({ date: new Date('2020-01-04'), workedHours: new WorkedHours(8) }),
      new DailyTimesheet({ date: new Date('2020-01-05'), workedHours: new WorkedHours(8) }),
      new DailyTimesheet({ date: new Date('2020-01-06'), workedHours: new WorkedHours(0) }),
      new DailyTimesheet({ date: new Date('2020-01-07'), workedHours: new WorkedHours(0) }),
    ],
  });
 
  it('retourne le total des heures travaillées', () => {
    expect(weeklyTimesheet.workedHours.value).toBe(40);
  });
 
  it('produit une nouvelle feuille de temps hebdomadaire avec une feuille de temps quotidienne mise à jour', () => {
    const newWeeklyTimesheet = weeklyTimesheet.produceWithDailyTimesheet(
      new DailyTimesheet({
        date: new Date('2020-01-01'),
        workedHours: new WorkedHours(0),
      }),
    );
 
    expect(newWeeklyTimesheet.days).toEqual([
      new DailyTimesheet({ date: new Date('2020-01-01'), workedHours: new WorkedHours(0) }),
      weeklyTimesheet.days[1],
      weeklyTimesheet.days[2],
      weeklyTimesheet.days[3],
      weeklyTimesheet.days[4],
      weeklyTimesheet.days[5],
      weeklyTimesheet.days[6],
    ]);
    expect(newWeeklyTimesheet).not toEqual(weeklyTimesheet);
  });
});

Veuillez noter que nous essayons d'utiliser autant de valeurs en lecture seule que possible pour encapsuler le tout et éviter des mutations accidentelles.

J'espère que cela représente bien et de manière fidèle notre logique métier ! Nous pouvons donc facilement implémenter notre interface utilisateur en fonction de ce qu'on connaît de nos modèles.

Mais attendez ! Next.js n'autorise pas l'envoi d'instances de classes depuis le serveur Next à notre client ! Si nous le tentons, nous sommes accueillis par cette charmante erreur :

Erreur de sérialisation

Afin de contourner ce problème, nous pouvons utiliser la technique ancestrale de sérialisation / désérialisation.

Prenons exemple sur le modèle WorkingHours et ajoutons une méthode toJSON et une méthode statique fromJSON.

export interface WorkedHoursJSON {
  __type: 'WorkedHours';
  value: number;
}
 
export class WorkedHours {
  // ...
 
  toJSON(): WorkedHoursJSON {
    return {
      __type: this.__type,
      value: this.value,
    };
  }
 
  public static fromJSON(json: WorkedHoursJSON): WorkedHours {
    return new WorkedHours(json.value);
  }
}
Vous pouvez vous demander : "Pourquoi est-ce qu'on déclare toujours le `__type` partout ??!"

Comme vous le savez, TypeScript utilise le duck typing pour matcher les objets, ce qui signifie que ...

class Foo {
  value: number;
}
class Bar {
  value: number;
}
const foo: Foo = new Bar(); // pas d'erreur ( ͡ಠ ʖ̯ ͡ಠ)

En définissant readonly __type (ou tout autre champ discriminant), le compilateur lève désormais correctement une erreur. Cela s'appelle également branded types.

class Foo {
  __type: 'Foo';
  value: number;
}
class Bar {
  __type: 'Bar';
  value: number;
}
const foo: Foo = new Bar(); // erreur ( ˘ ³˘)♥

Mot de la fin

J'espère que cet article vous a permis de vous plonger dans l'utilisation d'Immer pour gérer l'immuabilité dans les applications React et d'appliquer les principes du Domain Driven Design (DDD) pour mieux représenter la logique métier. En découplant notre logique métier de notre logique d'interface utilisateur, nous pouvons facilement tester notre code et le maintenir à l'avenir.

Happy frontend coding everybody ! ❤️