HC

← Retour aux projets← Back to projects

§ PRJ-04 Fiche projetProject sheet

Mini-framework réactifReactive mini-framework

Un framework front réactif écrit de zéro en TypeScript : patterns de conception, Observable, store, routeur, composants à cycle de vie. Zéro dépendance.A reactive front-end framework written from scratch in TypeScript: design patterns, observables, store, router, lifecycle components. Zero dependencies.

StatutStatus
AcadémiqueAcademic
PériodePeriod
Juil. 2026Jul. 2026
ContexteContext
Module Design Patterns — ESGI 3IWDesign Patterns module — ESGI, 3rd year
RôleRole
Cœur du framework (Factory, Builder, Singleton, Strategy, Observer), infrastructure Docker et tests — équipe de 3Framework core (Factory, Builder, Singleton, Strategy, Observer), Docker infrastructure and tests — team of 3
Équipe Team
3 personnes 3 people
StackStack
TypeScriptViteVitestDockerNginx

01 · Contexte & objectif

Écrire un mini-framework web réactif en TypeScript, en n’utilisant aucune bibliothèque externe — Vite servait uniquement au bundling. React, Vue, Svelte, jQuery, Lodash : tous interdits. Il ne restait que l’API DOM native.

L’énoncé demandait d’y faire vivre les patrons de conception vus en cours, et d’aller jusqu’aux Observables. Le barème, sur 20 points, était très inégalement réparti : Strategy pesait 5 points à lui seul, Observer 2, et chacun des autres patterns 1. Ce déséquilibre disait où était le vrai sujet — et ce n’était pas d’aligner des patterns pour la forme.

Dix jours, à trois. Je me suis chargé du cœur du framework : les cinq patterns, l’infrastructure Docker et les tests unitaires.

Application de démonstration TaskFlow : liste de tâches avec champ d'ajout, filtres Toutes / À faire / Faites, et cases à cocher.
L'application de démonstration, construite entièrement avec le framework : routeur, store, composants et liaison Observable → DOM.The demo application, built entirely with the framework: router, store, components and Observable → DOM binding.

02 · Le point dur : Strategy sur trois backends de stockage

Le store devait pouvoir persister son état en mémoire, dans le localStorage, ou dans IndexedDB — et changer de backend à l’exécution, sans que le store lui-même ne soit modifié.

Le piège est dans la nature des trois API. Map et localStorage sont synchrones : la valeur est là, tout de suite. IndexedDB est asynchrone : ouverture de base, transaction, événements onsuccess / onerror. Un contrat commun naïf — get(key): T — exclut donc IndexedDB d’entrée. Et un contrat qui l’accepterait par un détour quelconque forcerait l’appelant à savoir quel backend est branché, ce qui annule tout l’intérêt du pattern.

La seule interface qui tienne les trois est celle qui s’aligne sur le plus contraignant : tout retourne une Promise, y compris les backends synchrones, qui se contentent d’un Promise.resolve(). Le code appelant écrit await sans jamais savoir ce qu’il y a derrière, et setStrategy() peut permuter les implémentations à chaud.

Détail ajouté au-delà de l’énoncé : LocalStorageAdapter préfixe ses clés. Sans ça, son clear() viderait tout le localStorage du domaine, y compris ce qui ne lui appartient pas — un effet de bord invisible en développement, et pénible en production.

03 · Les autres patterns

Factory sans if ni switch. L’énoncé l’exigeait explicitement. Les huit types d’éléments (button, div, image, hr, input, heading, span, paragraph) sont enregistrés dans un Record<ElementType, TagConstructor> : ajouter un type consiste à ajouter une entrée, jamais à rallonger un branchement. Une classe abstraite partagée applique la configuration commune, chaque tag concret ne fournit que son nom de balise.

Builder à interface fluide. Des méthodes with* qui retournent this, et leurs symétriques without*. Il complète la Factory plutôt qu’il ne la double : la Factory pour créer vite un élément simple, le Builder pour composer des arbres imbriqués.

Observable. Le socle de toute la réactivité — le binding DOM, le store et le routeur sont tous construits dessus. Deux choix qui ne sont pas dans l’énoncé mais qui changent l’usage : subscribe() émet immédiatement la valeur courante — sinon un composant qui s’abonne après une émission reste vide jusqu’au prochain changement — et les abonnés sont stockés dans un Set, ce qui rend l’abonnement idempotent. Le désabonnement est retourné par subscribe(), pour éviter les fuites mémoire à la destruction d’un composant.

Singleton. AppConfig et AppStore, constructeur privé, getInstance(). AppStore reçoit une StorageStrategy : le Singleton porte l’état, la Strategy décide où il est persisté.

04 · Ce que j’ai ajouté ensuite

Le client HTTP renvoie un Result<T, HttpError> plutôt que de lever une exception : une erreur réseau devient une valeur qu’on traite, pas un flux de contrôle parallèle qu’on peut oublier d’attraper.

J’y ai greffé des interceptors, encore par Strategy — un AuthInterceptor qui ajoute un jeton porteur quand il y en a un, un LoggingInterceptor qui trace requête et réponse. Ils étaient optionnels au barème ; c’était l’endroit le plus naturel pour réutiliser le pattern hors du stockage.

05 · Un piège qui m’a coûté du temps

Les tests de LocalStorageAdapter passaient — mais ne testaient rien.

Node 22 embarque une implémentation expérimentale et native de localStorage. Sous Vitest, elle prenait le pas sur celle de jsdom : la suite exerçait le localStorage de Node, pas celui du navigateur qu’on prétendait vérifier. Un test vert qui ne prouve rien est pire qu’un test rouge, parce qu’il ferme la question.

Correctif : lancer Vitest avec NODE_OPTIONS=--no-experimental-webstorage, pour que jsdom reprenne la main.

06 · Qualité et livraison

86 tests Vitest, un fichier par brique. strict: true, plus noUnusedLocals et noUnusedParameters — et zéro any dans les sources. JSDoc sur les classes, interfaces et méthodes publiques.

Livraison en image Docker multi-étages : Node compile, Nginx sert le dist/ statique avec un repli SPA pour que le routeur côté client fonctionne sur un rechargement en profondeur.

07 · Ce que j’en retire

Un projet « appliquer les design patterns » invite à les plaquer : on écrit une Factory parce qu’il faut une Factory, et elle ne résout rien. Le barème d’ici protégeait contre ça en mettant cinq points sur le seul pattern qui posait une vraie question — faire cohabiter trois API dont l’une est asynchrone.

Ce que j’en garde n’est pas la mécanique des patterns, elle s’oublie et se relit. C’est le réflexe de chercher la contrainte la plus forte avant de définir une interface. En raisonnant depuis Map, on obtient un contrat synchrone élégant qu’IndexedDB fait exploser une semaine plus tard. En raisonnant depuis IndexedDB, on obtient un contrat que les trois honorent — un peu plus lourd pour les cas simples, mais qui n’a jamais eu besoin d’être retouché.

01 · Context & goal

Write a reactive web mini-framework in TypeScript using no external library — Vite was allowed for bundling only. React, Vue, Svelte, jQuery, Lodash: all banned. What remained was the native DOM API.

The brief asked us to put the course’s design patterns to work in it, all the way through to observables. The 20-point marking scheme was very unevenly weighted: Strategy alone was worth 5 points, Observer 2, and every other pattern 1. That imbalance said where the real subject was — and it was not about lining up patterns for show.

Ten days, in a team of three. I took the framework core: the five patterns, the Docker infrastructure and the unit tests.

TaskFlow demo application: task list with an add field, All / To do / Done filters, and checkboxes.
L'application de démonstration.The demo application, built entirely with the framework: router, store, components and Observable → DOM binding.

02 · The hard part: Strategy across three storage backends

The store had to persist its state in memory, in localStorage or in IndexedDB — and switch backend at runtime, without the store itself changing.

The trap lies in the nature of the three APIs. Map and localStorage are synchronous: the value is right there. IndexedDB is asynchronous: opening a database, a transaction, onsuccess / onerror events. A naive common contract — get(key): T — therefore rules IndexedDB out from the start. And a contract that somehow worked around it would force callers to know which backend is plugged in, which defeats the whole point of the pattern.

The only interface that holds all three is the one that aligns with the most constrained: everything returns a Promise, including the synchronous backends, which simply hand back Promise.resolve(). Calling code writes await without ever knowing what sits behind it, and setStrategy() can swap implementations live.

One detail added beyond the brief: LocalStorageAdapter prefixes its keys. Without that, its clear() would wipe the domain’s entire localStorage, including keys that are none of its business — a side effect that is invisible in development and painful in production.

03 · The other patterns

Factory with no if and no switch. The brief required it explicitly. The eight element types (button, div, image, hr, input, heading, span, paragraph) are registered in a Record<ElementType, TagConstructor>: adding a type means adding an entry, never extending a branch. A shared abstract class applies the common configuration; each concrete tag only supplies its tag name.

Builder with a fluent interface. with* methods returning this, and their without* counterparts. It complements the Factory rather than duplicating it: the Factory to create a simple element quickly, the Builder to compose nested trees.

Observable. The backbone of all reactivity — DOM binding, the store and the router are all built on it. Two choices that are not in the brief but change how it is used: subscribe() emits the current value immediately — otherwise a component subscribing after an emission stays empty until the next change — and subscribers live in a Set, which makes subscription idempotent. The unsubscribe function is returned by subscribe(), to avoid memory leaks when a component is destroyed.

Singleton. AppConfig and AppStore, private constructor, getInstance(). AppStore takes a StorageStrategy: the Singleton holds the state, the Strategy decides where it is persisted.

04 · What I added afterwards

The HTTP client returns a Result<T, HttpError> rather than throwing: a network error becomes a value you handle, not a parallel control flow you can forget to catch.

Onto it I grafted interceptors, again through Strategy — an AuthInterceptor that attaches a bearer token when there is one, a LoggingInterceptor that traces request and response. They were optional in the marking scheme; this was the most natural place to reuse the pattern outside storage.

05 · A trap that cost me time

The LocalStorageAdapter tests passed — but tested nothing.

Node 22 ships an experimental native implementation of localStorage. Under Vitest it took precedence over jsdom’s: the suite was exercising Node’s localStorage, not the browser one we claimed to be verifying. A green test that proves nothing is worse than a red one, because it closes the question.

Fix: run Vitest with NODE_OPTIONS=--no-experimental-webstorage, so jsdom takes over again.

06 · Quality and delivery

86 Vitest tests, one file per building block. strict: true, plus noUnusedLocals and noUnusedParameters — and zero any in the sources. JSDoc on classes, interfaces and public methods.

Delivered as a multi-stage Docker image: Node builds, Nginx serves the static dist/ with an SPA fallback so the client-side router survives a deep reload.

07 · What I take away

A project framed as “apply the design patterns” invites you to bolt them on: you write a Factory because a Factory is required, and it solves nothing. This marking scheme guarded against that by putting five points on the one pattern that raised a real question — making three APIs coexist when one of them is asynchronous.

What I keep from it is not the mechanics of the patterns; those are forgotten and looked up again. It is the reflex of looking for the strongest constraint before defining an interface. Reason from Map and you get an elegant synchronous contract that IndexedDB blows up a week later. Reason from IndexedDB and you get a contract all three can honour — slightly heavier for the simple cases, but one that never needed touching again.