J’ai commencé un petit projet pour une association de jeux de société. Une petite application web qui permet aux gens de s’inscrire aux soirées organisées. Comme d’habitude j’ai commencé à la main et en une matinée 80% était fait. L’après-midi, il ne me restait qu’une seule chose à faire : sauvegarder le formulaire d’inscription pour pouvoir le pré-remplir automatiquement les prochaines fois.
Pour ce faire, comme j’utilise Phoenix qui est un framework backend, il faut créer un hook. On place celui-ci sur le formulaire et on réagit aux événements tels que submit. C’est une mécanique non-triviale qui me demande à chaque fois de relire la documentation.
Je décide donc que c’est l’occasion parfaite pour retenter l’aventure du vibe-coding. Je n’avais pas encore testé OpenCode dont on entend parler partout. J’en profite aussi pour essayer DeepSeek 3.2 qui est conçu pour les agents IA. J’ai vu que Claude commence à fermer son écosystème, donc je teste la concurrence. DeepSeek facture au million de tokens. Je n’ai aucune idée de ce que représente un million de tokens du coup je mets 10$ en pensant rallonger si besoin.
Le setup de OpenCode et DeepSeek est intuitif et ne prend qu’une minute. C’est très bien fait.
Vibe coding
Disclaimer : Je n’ai lu absolument aucune documentation : ni sur OpenCode, ni sur DeepSeek, ni sur le développement avec agents IA. Peut-être que tous les inconvénients que je remonterais seront juste dûs à mon ignorance.
Mon premier prompt est le suivant :
Prompt : when the modal’s form is submitted, save the firstname, lastname, and count on localstorage. The modal’s form should defaults to these values if they are present
Elle se met donc à analyser le code, me fait une todo list et commence à faire les étapes 1 par 1. Au bout de 10 minutes de va-et-vient, son travail est achevé. Elle a décidé de passer par un ColocatedHook et ça ne fonctionne pas, rien est écrit dans le localstorage. De plus, un événement “stop” est apparu côté frontend qui n’existe pas côté backend, ce qui fait planter la LiveView. Il m’a fallu 4 autres prompts et probablement 30 à 40 minutes pour régler les problèmes et réfactorer le code (pour utiliser un hook normal). Pendant tout ce temps, elle me produit des proses pour justifier ces choix. On parle d’articles entiers à chaque étape de son travail. Ca donne moyennement envie de les lire :
Je me contente donc de lire le résumé à la fin.
C’est assez incroyable de le voir faire, défaire, refaire. J’ai vraiment eu l’impression qu’elle faisait du bruteforce : essayer toutes les possibilités jusqu’à ce que ça passe. Au passage, à aucun moment dans sa todo-list elle n’a eu l’idée d’écrire des tests… Du coup faire tourner les tests unitaires à chaque fois, ne sert pas à grand chose !
A un moment je lui ai demandé une chose simple : afficher les erreurs dans un formulaire et il est parti pour 15 minutes de travail qui n’avait rien à voir. Elle a décidé de rendre l’application plus accessible en ajoutant des attributs aria, des placeholders, ajoute des boutons de réinitialisation de formulaire, etc. En soi, ce sont sûrement des changements acceptables mais je ne les ai pas demandés ! Je n’ai pas besoin de rendre mon application accessible, du moins pas pour le moment.
Parfois, entre deux prompts, je change des choses (comme des labels de formulaires), et je me rends compte qu’elle les remplace sans vergogne au prochain prompt. Par exemple, un bouton “S’inscrire” deviendra “Valider”.
Qualité du code produit
Pour tout ce qui est “standard”, il n’y a rien à dire. Elle fait ce qu’il faut là où il faut. Par contre dès que l’on sort des sentiers battus, c’est un peu la catastrophe. En fait j’ai l’impression qu’il faut lui tenir la main et être super précis dans les prompts. Moins elle fait de choses, meilleures elles sont. Voici le fameux hook créé après 5 prompts :
const LocalStorageForm = {
mounted() {
console.log("LocalStorageForm mounted on:", this.el.id);
// Find input fields by name pattern (supports both participant[field] and settings[field])
const firstnameInput = this.el.querySelector('[name*="[firstname]"]');
const lastnameInput = this.el.querySelector('[name*="[lastname]"]');
const countInput = this.el.querySelector('[name*="[count]"]');
// Load and set firstname
const savedFirstname = localStorage.getItem("laaps_firstname");
console.log("Loaded firstname from localStorage:", savedFirstname);
if (firstnameInput && savedFirstname) {
firstnameInput.value = savedFirstname;
firstnameInput.dispatchEvent(new Event("input", { bubbles: true }));
}
// Load and set lastname
const savedLastname = localStorage.getItem("laaps_lastname");
console.log("Loaded lastname from localStorage:", savedLastname);
if (lastnameInput && savedLastname) {
lastnameInput.value = savedLastname;
lastnameInput.dispatchEvent(new Event("input", { bubbles: true }));
}
// Load and set count - default to 1 if not in localStorage
const savedCount = localStorage.getItem("laaps_count");
console.log("Loaded count from localStorage:", savedCount);
if (countInput) {
if (savedCount && savedCount !== "") {
countInput.value = savedCount;
} else {
countInput.value = "1";
}
countInput.dispatchEvent(new Event("input", { bubbles: true }));
}
// Add event listeners to save on input changes
this.saveFormData = this.saveFormData.bind(this);
const form = this.el;
form.addEventListener("input", this.saveFormData);
// Also save on form submit
this.saveOnSubmit = this.saveOnSubmit.bind(this);
form.addEventListener("submit", this.saveOnSubmit);
// Listen for clear:localstorage events
this.handleClearLocalStorage = this.clearLocalStorage.bind(this);
form.addEventListener("clear:localstorage", this.handleClearLocalStorage);
},
destroyed() {
// Remove event listeners
this.el.removeEventListener("input", this.saveFormData);
this.el.removeEventListener("submit", this.saveOnSubmit);
this.el.removeEventListener(
"clear:localstorage",
this.handleClearLocalStorage,
);
// Save final state
this.saveFormData();
},
saveFormData() {
this.saveToLocalStorage();
},
saveOnSubmit(e) {
// LiveView handles the actual form submission via phx-submit
// We just save to localStorage as well
this.saveToLocalStorage();
},
clearLocalStorage() {
console.log("Clearing localStorage...");
// Remove localStorage items
localStorage.removeItem("laaps_firstname");
localStorage.removeItem("laaps_lastname");
localStorage.removeItem("laaps_count");
// Clear form inputs
const firstnameInput = this.el.querySelector('[name*="[firstname]"]');
const lastnameInput = this.el.querySelector('[name*="[lastname]"]');
const countInput = this.el.querySelector('[name*="[count]"]');
if (firstnameInput) {
firstnameInput.value = "";
firstnameInput.dispatchEvent(new Event("input", { bubbles: true }));
}
if (lastnameInput) {
lastnameInput.value = "";
lastnameInput.dispatchEvent(new Event("input", { bubbles: true }));
}
if (countInput) {
countInput.value = "1";
countInput.dispatchEvent(new Event("input", { bubbles: true }));
}
console.log("LocalStorage cleared and form inputs reset");
},
saveToLocalStorage() {
// Find input fields by name pattern
const firstnameInput = this.el.querySelector('[name*="[firstname]"]');
const lastnameInput = this.el.querySelector('[name*="[lastname]"]');
const countInput = this.el.querySelector('[name*="[count]"]');
// Save each field to separate localStorage keys
if (firstnameInput) {
localStorage.setItem("laaps_firstname", firstnameInput.value || "");
}
if (lastnameInput) {
localStorage.setItem("laaps_lastname", lastnameInput.value || "");
}
if (countInput) {
localStorage.setItem("laaps_count", countInput.value || "1");
}
},
};
Le code est hyper défensif car quand ça ne fonctionne pas, elle ajoute des tas de check partout en espérant qu’un de ceux-ci corrigera le problème. Pour un développeur ce genre de code n’est pas agréable à lire et encore moins à modifier. Il y a beaucoup de répétitions et même des choses complètement inutiles. Par exemple le listener d’input sur le formulaire. Je n’ai aucunement besoin de sauvegarder dans le localstorage à chaque appui sur une touche. Les dispatchEvent sont aussi complètement inutiles : quel est l’intérêt de sauvegarder quelque chose que l’on vient de charger ou de nettoyer ?
Il ne s’agit ici que d’un exemple très simple et il est facile de le refactor comme on va le voir un peu plus bas mais imaginez ce genre de code sur tout un projet. Bon courage aux développeurs qui doivent maintenir ça. L’IA produit tellement de contenu à lire, que ce soit ses explications ou son code, qu’il est très facile de laisser passer des absurdités…
Du coup, j’ai fait un petit refactor du hook :
const LocalStorageForm = {
mounted() {
this.$firstname = this.el.querySelector('[name*="[firstname]"]');
this.$lastname = this.el.querySelector('[name*="[lastname]"]');
this.$count = this.el.querySelector('[name*="[count]"]');
if (!this.$firstname || !this.$lastname || !this.$count)
throw new Error("Missing some inputs for the hook");
this.$firstname.value = localStorage.getItem("laaps_firstname");
this.$lastname.value = localStorage.getItem("laaps_lastname");
this.$count.value = localStorage.getItem("laaps_count") || 1;
this.el.addEventListener("submit", this.saveToLocalStorage.bind(this));
this.el.addEventListener(
"clear:localstorage",
this.clearLocalStorage.bind(this),
);
},
destroyed() {
this.el.removeEventListener("submit", this.saveToLocalStorage.bind(this));
this.el.removeEventListener(
"clear:localstorage",
this.clearLocalStorage.bind(this),
);
},
clearLocalStorage() {
localStorage.removeItem("laaps_firstname");
localStorage.removeItem("laaps_lastname");
localStorage.removeItem("laaps_count");
},
saveToLocalStorage() {
this.clearLocalStorage();
if (this.$firstname.value)
localStorage.setItem("laaps_firstname", this.$firstname.value);
if (this.$lastname.value)
localStorage.setItem("laaps_lastname", this.$lastname.value);
if (this.$count.value)
localStorage.setItem("laaps_count", this.$count.value);
},
};
Évidemment c’est facile de faire ça après… Peut-être même que l’IA aurait pu en arriver là après quelques prompts de refactor. Moi ça m’a pris moins de 5 minutes. Je doute que j’aurais pu atteindre cette qualité sans y passer 30 minutes d’aller-retour. Je ne suis plus un expert javascript, il y a peut-être des choses à redire mais quand je lis ce code, je comprends l’intention.
Coûts
Cet après-midi (disons 3h de travail) m’aura coûté au final $0.65 pour 17.8 millions de tokens. C’est vraiment pas cher. On se demande pourquoi plus aucune entreprise ne recrute de développeurs…
Avantages
- Coûts faibles
- Vitesse de développement exceptionnelle
- Autonomie
Inconvénients
- Elle déroule tout sans s’interrompre
- Elle ne pose aucune question
- Elle fait des choses que je ne lui ai pas demandées (aria, placeholder, reset form…)
- Il faut être très rigoureux avec les prompts afin de laisser le moins de marge de manœuvre possible
- Elle renomme les choses à son bon vouloir
- La qualité du code produit est parfois mauvaise si on laisse faire
- Moins on précise de choses, plus on se retrouve avec un produit “standard”
Conclusion
Il est encore un peu tôt pour rendre des conclusions et il me reste $9.35 pour continuer un peu. Pour finir ce projet, j’ai repris la main. Je préfère me stimuler à faire, c’est d’ailleurs pour ça que je suis développeur.
J’ai souvent comparer le développement avec des Legos : on assemble des briques pour arriver au produit final. Avec la standardisation de l’IA, on arrive fatalement à des produits standardisés. C’est un peu comme si l’IA construisait des assemblages Legos magnifiques mais creux et tout gris. Creux parce qu’elle ne connaît que la façade extérieure du produit et gris parce que c’est la couleur la plus utilisée dans les Legos qu’elle a étudié.
En tout cas, à chaque fois que je vibe code, je suis impressionné de voir la puissance de cet outil. Elle analyse le code et est capable d’aller réutiliser des fonctions existantes quitte à les modifier un peu pour les rendre plus flexibles. Je pense pourtant n’avoir effleuré qu’un faible pourcentage des capacités des agents. Avant d’attaquer un autre projet, je prendrai le temps de lire un peu sur les bonnes pratiques du vibe coding. Le sujet est tellement récent qu’il n’y a pas encore de norme et chacun y va de son grain de sel.