On est donc reparti pour faire notre plus beau projet « todo list » en mode « clean architecture ». Comme vous le savez, les avancées seront mise sur mon github et sera directement visible sur https://naughty-banach-1ccbd2.netlify.app.
Au programme aujourd’hui, passer d’un component « simple » à un component qui utilise un modelView/Presenter. Je dis « simple », mais il est déjà pas si mal! On a des useState, des useMemo, des useEffect, des appels asynchrones, etc…
ps: Prenez votre temps pour lire les étapes de refac 😇
C’est quoi le but dans l’histoire?
En fait, l’idée est simple. Techniquement, il faudrait:
- Donner à la vue un viewModel qui contient toutes les informations prêtes à être affichées. Si vous avez des dates, ce sont déjà des string bien formatées et pas des Date. Si vous avez des champs à griser, il y a un booléen du genre isButtonDisabled. Les prix ont déjà le bon format. Bref vous voyez l’idée
- Il faut aussi donner à la vue un Presenter qui contient les méthodes que celle-ci appellera quand on veut effectuer des actions. Par exemple: loadTodos(), toggleTodo(), addTodo(), etc… Le Presenter se chargera de modifier le viewModel et d’en avertir la vue.
Le but étant de simplifier au maximum la vue, car son seul but sera d’afficher les infos du viewModel au bon endroit (comme un placeholder) et d’appeler les méthodes du Presenter au bon moment (via un onClick=>presenter.addTodo()).
Et concrètement?
On va partir en mode « refactoring » pour arriver à une solution vivable.
1. Création du Presenter et du HOC
Commençons par faire notre Presenter qui s’occupera de donner une liste statique à notre vue. Les injections de Presenter et de viewModel se feront via un HOC (Higher-Order Component).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// 1. A simple Presenter with a predefined list of todos class TodoListPresenter { constructor() { this.viewModel = { todos: [ createTodo('Frozen yoghurt', false), createTodo('Ice cream sandwich', false), createTodo('Eclair', false), createTodo('Cupcake', false), createTodo('Gingerbread', false), ], }; } } //2. Component receives a presenter and a viewModel const TodoList = ({ presenter, viewModel }) => { // The list come from the viewModel const [todos, setTodoList] = useState(viewModel.todos); //... }); // 3. We will make a "HOC" to wrap the original component. This will allow us to easily pass a presenter and its viewModel export const withMVP = (Wrapped) => function WithTodoPresenter() { // We just make sure that the presenter will be instantiated only the first time const presenter = useMemo(() => { return new TodoListPresenter(); }, []); return <Wrapped presenter={presenter} viewModel={presenter.viewModel}/>; }; // 4. Now we export the "HOCed" component export default withMVP(TodoList); |
Code complet dispo : https://github.com/Nikoms/clean-todo/pull/6/commits/26e99132790e5e69caa636c26b0e2995e36db4c1?diff=split&w=1
2. Interagir avec notre liste
Avoir une liste, c’est bien. Pouvoir interagir avec celle-ci, c’est mieux. Dans ce commit, nous allons refac deux choses:
- Les fonctions addEmptyTodo et toggleDone vont appeler le Presenter.
- La modification du viewModel devra re-renderer la vue avec les nouvelles données. Le Presenter aura désormais un « listener » (une callback) qu’il appellera à chaque modification. Enfin, le HOC va créer un useState qui, quand il sera set, rafraichira le component. Les plus attentifs verront que le listener/callback du Presenter n’est autre que le setState 😎
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
class TodoListPresenter { // ... // 1. This is how we set the listener of the presenter onViewModelChange(callback) { this.viewModelListener = callback; } // 2. This method will be called by the view setTodoList(todos) { this.viewModel.todos = todos; //Calling the listener with a copy of the viewModel ("immutable" style) this.viewModelListener({...this.viewModel}); } } const TodoList = ({presenter, viewModel}) => { //... // 3. Refac from this: // const addEmptyTodo = () => setTodoList([createTodo('Relax! Edition will come...', false), ...todos]); // To this: const addEmptyTodo = () => presenter.setTodoList([createTodo('Relax! Edition will come...', false), ...todos]); // 4. Same job for "toggleDone" that calls the presenter const toggleDone = (index) => presenter.setTodoList(/*...*/); }); // 5. Very important, our HOC will manage the listener and re-render the view thanks to a useState export const withMVP = (Wrapped) => function WithTodoPresenter() { // The purpose of this viewModel is to keep the last version of the presenter's viewModel // AND it will also help us to re-render the Component thanks to "setViewModel". const [viewModel, setViewModel] = useState(); const presenter = useMemo(() => { const presenter = new TodoListPresenter(); // When the viewModel changes, give it to "setViewModel" to reload the component with the new value presenter.onViewModelChange(setViewModel); return presenter; }, []); // The first time, viewModel will be undefined. When "onViewModelChange" will be called, viewModel will have the last value return <Wrapped presenter={presenter} viewModel={viewModel || presenter.viewModel}/>; }; }; |
Code complet dispo: https://github.com/Nikoms/clean-todo/pull/6/commits/98fb4046dc5ab5d819b2df34c232c02c522453f4
3. Rendre notre Presenter plus explicite
Dans l’exemple précédent, la vue appelle un presenter.setTodoList() à chaque opération de la liste. Le but étant de simplifier la vue, on va créer des méthodes spécifiques dans le Presenter: addEmptyTodo() et toggleDone(index)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class TodoListPresenter{ //... // 1. Here are our 2 new methods addEmptyTodo() { this._setTodoList([createTodo('Relax! Edition will come...', false), ...this.viewModel.todos]); } toggleDone(index) { this._setTodoList([ ...this.viewModel.todos.slice(0, index), {...this.viewModel.todos[index], done: !this.viewModel.todos[index].done}, ...this.viewModel.todos.slice(index + 1)]); } // This one is "private" and will never be called by the view _setTodoList(todos) { this.viewModel.todos = todos; this.viewModelListener({...this.viewModel}); } } const TodoList = ({presenter, viewModel}) => { //... // 2. Make sure to call presenter methods // Before: // <button onClick={addEmptyTodo}>Add</button> // After: <button onClick={() => presenter.addEmptyTodo()}>Add</button> // 3. Same for "toggleDone" }; |
Code complet dispo: https://github.com/Nikoms/clean-todo/pull/6/commits/4c9d426e01af0a912c2a9254c4d931d62ec47606
4. Et nos compteurs de ongoing/done ?
Il reste de la logique dans notre vue. Pour le moment, on a ceci:
1 2 3 |
const todos = viewModel.todos; const ongoingCount = useMemo(() => todos.filter(t => !t.done).length, [todos]); const doneCount = useMemo(() => todos.filter(t => t.done).length, [todos]); |
Ce qui n’est pas bon, car toutes les infos affichées doivent venir du viewModel. C’est à notre Presenter de faire le boulot. Heureusement (il y a findus), tout est déjà centralisé dans la méthode Presenter._setTodoList(list). Il suffit de faire les filter+length à ce moment là:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class TodoListPresenter { // ... _setTodoList(todos) { this.viewModel.todos = todos; // this.viewModel.doneCount = todos.filter(t => t.done).length; this.viewModel.ongoingCount = todos.filter(t => !t.done).length; this.viewModelListener({...this.viewModel}); } // ... } // And in the JSX: return <> // ... // Before: // <th rowSpan={2} align="left">My todos ({ongoingCount} ongoing /{doneCount} done) // After: <th rowSpan={2} align="left">My todos ({viewModel.ongoingCount} ongoing /{viewModel.doneCount} done) // ... |
Code dispo ici: https://github.com/Nikoms/clean-todo/pull/6/commits/396c1061933f9893d87322b525aeb7a3c360c91e
5. Async todos
Pour les besoins du refac, on avait initialiser notre liste de todos en dur dans le code (🤢). Nous allons repasser en mode asynchrone et demander à la vue d’appeler Presenter.loadTodos() quand elle est prête (onMount). On pourrait aussi charger la liste dans le constructeur du Presenter, mais je préfère laisser les constructeurs les plus « pures » possible 😁. Note importante: Nous injectons la méthode (getTodos) qui sera utilisée pour récupérer la liste car le Presenter ne veut pas savoir d’où ça vient (Dependency inversion): Cela pourrait être une méthode qui appelle une API REST ou GraphQL, un mock, etc… « Not our business ».
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// 1. In our HOC, we make sur to pass the function to get the todos const presenter = useMemo(() => { // import {getTodos} from './todo.service'; const presenter = new TodoListPresenter(getTodos); //... }, []); class TodoListPresenter { // 2. The presenter receives method(s) that will be called later. Ex: getTodos, login, register, getBlogPosts, etc... . Because we don't want the view to call these. The view only know the presenter (kind of hub) and the viewModel constructor(getTodos) { this.useCase = { getTodos, }; // ... } loadTodos() { this.useCase.getTodos().then(todos => this._setTodoList(todos)); } } const TodoList = ({presenter, viewModel}) => { // 3. The view call the presenter as soon as it gets loaded useEffect(() => { presenter.loadTodos(); }, [presenter]); //... } |
Code dispo ici: https://github.com/Nikoms/clean-todo/pull/6/commits/41ca449d64ebbb52427ab0424318b9e8202d0218
Et pour les tests
Hé ben pour les tests c’est beaucoup plus simple! En effet, techniquement, vous ne devriez plus avoir besoin de tests se basant sur votre component (ou si peu). Pour info, dans ce commit, vous avez une comparaison des tests écrits en utilisant le component et les tests écrits en utilisant directement le Presenter. Résultat des courses:
- Les tests sont bien plus rapides: 300ms contre 6ms maintenant
- Les tests sont robustes car ne se n’utilisent pas la vue (jsx, dom, …)
- Le code métier n’es pas mélangé à la vue
- On utilise du javascript pure. C’est tout une partie de code qui ne devra pas changer à chaque mise à jour d’une dépendance (je suppose que vous aussi, vous en avez 2-3…)
Vous pouvez toujours avoir un ou deux petits tests qui couvre le component, mais ils devraient survoler l’ensemble… Pour le coup, je testerais l’intégration manuellement, car écrire les tests équivaudrait à savoir si on a bien copier/coller la variable au bon endroit (tel un placeholder). Des exceptions sont toujours possibles, mais pour moi, le ratio entre le temps passer à écrire/maintenir le test et le gain apporté, n’est pas assez interessant. Je pense que les tests E2E couvriraient déjà suffisamment le code.
C’est tout?
Oui c’est tout 😊 Bien sûr, j’ai encore effectué 2-3 refac sur le code comme:
- Créer une méthode refreshUI
- Avoir une méthode centralisée pour mettre à jour le viewModel « à la reducer »
- Bouger le Presenter dans un autre fichier
- Créer une class Presenter que tous les autres Presenter devront « extends »
- Refactorer un peu les tests pour tester que le listener soit bien appelé
Si vous voulez, la merge request est disponible sur Github. Elle contient tous les commits atomiques avec, comme d’habitude, chaque refac effectué est commenté, suivi d’un commit « Remove comment for the next refactoring » pour vous simplifier la lecture du refac suivant 🤓
Encore des questions?
Postez les en commentaires… Sinon j’anticipe déjà quelques-unes :
Tu nous fais des HOC avec des classes plutôt que des fonctions… Tu serais pas un peu has-been? Va faire de l’angular! Ils aiment les classes là bas 🔥
Monsieur M’palèclass
Je dois être has-been oui 🙂. Mais je fais partie de ceux qui pensent qu’on peut utiliser les 2 dans un projets. Par contre Angular, Vue ou React, pour moi c’est le même combat! Après, vous pouvez aller lire l’article sur intitulé Double Your React Coding Speed With This Simple Trick. Il commence par faire un hook où il exporte des fonctions/variables. J’imagine qu’il y a moyen d’écrire tout ce que j’ai décrit ici en mode « composition ». Si ça se trouve, je le ferai pour le challenge. Mais entre temps, je trouve le HOC plus simple pour les tests qu’un hook qu’il faut mocker. En effet, je n’ai qu’à instancier un Presenter, éventuellement appeler quelques méthodes pour me mettre dans une situation particulière (ex: presenter.openModal()), et ensuite le passer à ma vue avec son viewModel. « Et voilà », comme disent les anglais..
Ok, mais ça n’ira pas dans mon projet, j’utilise redux et pleins de hooks, c’est impossible à intégrer dans ton bazar…
Monsieur Nicroipa
Si si! On va en faire un sujet très prochainement (je suis en plein dedans au boulot…). En fait, il faut voir le truc comme un simple HOC qui ajoute 2 props à ton component… La prochaine fois que tu rajoutes un useState, essaye cette technique pour voir.
Et donc tu n’utilises pas la puissance de tout le framework? Les hooks comme useMemo, useEffect ou des trucs plus custom comme useFetch ou useAsync, etc…
Monsieur daypendanse
Effectivement, je ne les utilise que quand il faut et certainement pas dans le Presenter. Mon but est d’avoir un code qui tient dans le temps, pas d’utiliser toutes les technos et les libs en vogue. J’ai tellement eu de gros projets Legacy (des projets avec 15/20 ans de devs) et c’est tellement la galère de maintenir des dépendances que merci mais non merci. Chaque version major est un vrai challenge 🤓! De plus, je me méfie des frameworks/libs qui prennent des décisions pour nous (React qui passe des classes aux fonctions par exemple) sur les paradigmes, nos structures de dossier ou nos nommages de fichier. Ce n’est pas pour ça que je ne les utilise pas bien sûr, mais leur impact sur notre code doit être limité. Intégrer des hooks qui font des calls API dans la vue, je trouve que c’est se tirer une balle dans le pied et franchement pas plus « rapide » à programmer.
On fait ça pour tous les components alors?
Monsieur Bon’Kestillon
Non. Pour le moment, je me concentre sur les gros component qui représente une page. Les « sous components » ont dest props hyper spécifiques. Je ne ferais pas de MVP pour un champ texte par exemple.
La suite?
Si vous voulez me demander d’ajouter une fonctionnalité dans le code ou me lancer un challenge, vous pouvez ouvrir une issue. Au programme:
- Pouvoir éditer un todo (Mhhh, les formulaires, c’est jamais simple…)
- Enregistrer ma liste quelque part. On aura probablement un backend simulé qui enregistrera tout en localstorage.
Merci d’avoir eu le courage de tout lire 😘
Très bel article
Comme Cédric, j’avoue qu’il est ultra-stylé cet article !
Découpler la logique du framework c’est vraiment la base, je l’ai bien compris avec le DDD et la clean architecture.
Pourtant tout nous incite à faire du code hyper couplé aux frameworks… Merci Nico pour ta sagesse 🙂