[{"data":1,"prerenderedAt":5441},["ShallowReactive",2],{"blog-tous":3},[4,1073,1713,2098,2587,3107,3605,3913,4580,4902,4990,5127],{"id":5,"title":6,"body":7,"brouillon":1059,"date":1060,"description":1061,"extension":1062,"image":1063,"imageAlt":1063,"meta":1064,"navigation":305,"path":1065,"seo":1066,"stem":1067,"tags":1068,"__hash__":1072},"blog\u002Fblog\u002Fapp-fullstack-nuxt-nitro-prisma.md","Une app fullstack avec Nuxt : server routes Nitro et Prisma",{"type":8,"value":9,"toc":1049},"minimark",[10,14,23,37,66,77,181,210,214,217,264,451,454,477,481,488,537,544,548,624,627,760,770,774,781,1019,1028,1032,1035,1039,1045],[11,12,13],"p",{},"L'un des atouts les plus sous-estimés de Nuxt, c'est qu'il n'a pas besoin\nd'un backend séparé pour être fullstack. Le serveur est déjà là, sous le nom\nde Nitro. Ajoutez Prisma pour parler à une base de données, et vous tenez\nune application complète dans un seul dépôt, en TypeScript du front jusqu'à\nla requête SQL. Voici comment les pièces s'emboîtent.",[15,16,18,19],"h2",{"id":17},"le-dossier-server","Le dossier ",[20,21,22],"code",{},"server\u002F",[11,24,25,26,28,29,32,33,36],{},"Tout le code serveur vit dans ",[20,27,22],{},", à la racine du projet (hors de\n",[20,30,31],{},"app\u002F",", car il ne tourne jamais dans le navigateur). La convention la plus\nutile est ",[20,34,35],{},"server\u002Fapi\u002F"," : chaque fichier y devient un point d'accès HTTP.",[38,39,40,50,58],"ul",{},[41,42,43,46,47],"li",{},[20,44,45],{},"server\u002Fapi\u002Farticles.get.ts"," répond à ",[20,48,49],{},"GET \u002Fapi\u002Farticles",[41,51,52,46,55],{},[20,53,54],{},"server\u002Fapi\u002Farticles.post.ts",[20,56,57],{},"POST \u002Fapi\u002Farticles",[41,59,60,46,63],{},[20,61,62],{},"server\u002Fapi\u002Farticles\u002F[id].get.ts",[20,64,65],{},"GET \u002Fapi\u002Farticles\u002F42",[11,67,68,69,72,73,76],{},"Le suffixe (",[20,70,71],{},".get",", ",[20,74,75],{},".post",") fixe la méthode HTTP, et l'arborescence fixe\nl'URL. Comme pour les pages : le disque décrit l'API.",[78,79,84],"pre",{"className":80,"code":81,"language":82,"meta":83,"style":83},"language-typescript shiki shiki-themes github-dark","\u002F\u002F server\u002Fapi\u002Fhello.get.ts\nexport default defineEventHandler((event) => {\n  const { nom } = getQuery(event)\n  return { message: `Bonjour ${nom ?? 'le monde'}` }\n})\n","typescript","",[20,85,86,95,126,151,175],{"__ignoreMap":83},[87,88,91],"span",{"class":89,"line":90},"line",1,[87,92,94],{"class":93},"sAwPA","\u002F\u002F server\u002Fapi\u002Fhello.get.ts\n",[87,96,98,102,105,109,113,117,120,123],{"class":89,"line":97},2,[87,99,101],{"class":100},"snl16","export",[87,103,104],{"class":100}," default",[87,106,108],{"class":107},"svObZ"," defineEventHandler",[87,110,112],{"class":111},"s95oV","((",[87,114,116],{"class":115},"s9osk","event",[87,118,119],{"class":111},") ",[87,121,122],{"class":100},"=>",[87,124,125],{"class":111}," {\n",[87,127,129,132,135,139,142,145,148],{"class":89,"line":128},3,[87,130,131],{"class":100},"  const",[87,133,134],{"class":111}," { ",[87,136,138],{"class":137},"sDLfK","nom",[87,140,141],{"class":111}," } ",[87,143,144],{"class":100},"=",[87,146,147],{"class":107}," getQuery",[87,149,150],{"class":111},"(event)\n",[87,152,154,157,160,164,166,169,172],{"class":89,"line":153},4,[87,155,156],{"class":100},"  return",[87,158,159],{"class":111}," { message: ",[87,161,163],{"class":162},"sU2Wk","`Bonjour ${",[87,165,138],{"class":111},[87,167,168],{"class":100}," ??",[87,170,171],{"class":162}," 'le monde'}`",[87,173,174],{"class":111}," }\n",[87,176,178],{"class":89,"line":177},5,[87,179,180],{"class":111},"})\n",[11,182,183,186,187,189,190,194,195,198,199,72,202,205,206,209],{},[20,184,185],{},"defineEventHandler"," reçoit un ",[20,188,116],{}," (la requête), et ",[191,192,193],"strong",{},"ce que vous\nretournez devient la réponse JSON",". Pas de ",[20,196,197],{},"res.json()",", pas de\nsérialisation manuelle. Les utilitaires comme ",[20,200,201],{},"getQuery",[20,203,204],{},"readBody"," ou\n",[20,207,208],{},"getRouterParam"," sont auto-importés.",[15,211,213],{"id":212},"brancher-prisma","Brancher Prisma",[11,215,216],{},"Prisma est un ORM TypeScript : vous décrivez vos tables dans un schéma, il\ngénère un client typé et les migrations. Installation et schéma minimal :",[78,218,222],{"className":219,"code":220,"language":221,"meta":83,"style":83},"language-bash shiki shiki-themes github-dark","npm install -D prisma\nnpm install @prisma\u002Fclient\nnpx prisma init --datasource-provider sqlite\n","bash",[20,223,224,238,247],{"__ignoreMap":83},[87,225,226,229,232,235],{"class":89,"line":90},[87,227,228],{"class":107},"npm",[87,230,231],{"class":162}," install",[87,233,234],{"class":137}," -D",[87,236,237],{"class":162}," prisma\n",[87,239,240,242,244],{"class":89,"line":97},[87,241,228],{"class":107},[87,243,231],{"class":162},[87,245,246],{"class":162}," @prisma\u002Fclient\n",[87,248,249,252,255,258,261],{"class":89,"line":128},[87,250,251],{"class":107},"npx",[87,253,254],{"class":162}," prisma",[87,256,257],{"class":162}," init",[87,259,260],{"class":137}," --datasource-provider",[87,262,263],{"class":162}," sqlite\n",[78,265,269],{"className":266,"code":267,"language":268,"meta":83,"style":83},"language-prisma shiki shiki-themes github-dark","\u002F\u002F prisma\u002Fschema.prisma\ngenerator client {\n  provider = \"prisma-client-js\"\n}\n\ndatasource db {\n  provider = \"sqlite\"\n  url      = env(\"DATABASE_URL\")\n}\n\nmodel Article {\n  id      Int      @id @default(autoincrement())\n  titre   String\n  contenu String\n  publie  Boolean  @default(false)\n  cree_le DateTime @default(now())\n}\n","prisma",[20,270,271,276,286,296,301,307,318,328,348,353,358,369,392,401,409,428,446],{"__ignoreMap":83},[87,272,273],{"class":89,"line":90},[87,274,275],{"class":93},"\u002F\u002F prisma\u002Fschema.prisma\n",[87,277,278,281,284],{"class":89,"line":97},[87,279,280],{"class":100},"generator",[87,282,283],{"class":107}," client",[87,285,125],{"class":111},[87,287,288,291,293],{"class":89,"line":128},[87,289,290],{"class":111},"  provider ",[87,292,144],{"class":100},[87,294,295],{"class":162}," \"prisma-client-js\"\n",[87,297,298],{"class":89,"line":153},[87,299,300],{"class":111},"}\n",[87,302,303],{"class":89,"line":177},[87,304,306],{"emptyLinePlaceholder":305},true,"\n",[87,308,310,313,316],{"class":89,"line":309},6,[87,311,312],{"class":100},"datasource",[87,314,315],{"class":107}," db",[87,317,125],{"class":111},[87,319,321,323,325],{"class":89,"line":320},7,[87,322,290],{"class":111},[87,324,144],{"class":100},[87,326,327],{"class":162}," \"sqlite\"\n",[87,329,331,334,336,339,342,345],{"class":89,"line":330},8,[87,332,333],{"class":111},"  url      ",[87,335,144],{"class":100},[87,337,338],{"class":137}," env",[87,340,341],{"class":111},"(",[87,343,344],{"class":162},"\"DATABASE_URL\"",[87,346,347],{"class":111},")\n",[87,349,351],{"class":89,"line":350},9,[87,352,300],{"class":111},[87,354,356],{"class":89,"line":355},10,[87,357,306],{"emptyLinePlaceholder":305},[87,359,361,364,367],{"class":89,"line":360},11,[87,362,363],{"class":100},"model",[87,365,366],{"class":107}," Article",[87,368,125],{"class":111},[87,370,372,375,378,381,384,386,389],{"class":89,"line":371},12,[87,373,374],{"class":111},"  id      ",[87,376,377],{"class":137},"Int",[87,379,380],{"class":107},"      @id",[87,382,383],{"class":107}," @default",[87,385,341],{"class":111},[87,387,388],{"class":137},"autoincrement",[87,390,391],{"class":111},"())\n",[87,393,395,398],{"class":89,"line":394},13,[87,396,397],{"class":111},"  titre   ",[87,399,400],{"class":137},"String\n",[87,402,404,407],{"class":89,"line":403},14,[87,405,406],{"class":111},"  contenu ",[87,408,400],{"class":137},[87,410,412,415,418,421,423,426],{"class":89,"line":411},15,[87,413,414],{"class":111},"  publie  ",[87,416,417],{"class":137},"Boolean",[87,419,420],{"class":107},"  @default",[87,422,341],{"class":111},[87,424,425],{"class":137},"false",[87,427,347],{"class":111},[87,429,431,434,437,439,441,444],{"class":89,"line":430},16,[87,432,433],{"class":111},"  cree_le ",[87,435,436],{"class":137},"DateTime",[87,438,383],{"class":107},[87,440,341],{"class":111},[87,442,443],{"class":137},"now",[87,445,391],{"class":111},[87,447,449],{"class":89,"line":448},17,[87,450,300],{"class":111},[11,452,453],{},"On applique le schéma à la base et on génère le client :",[78,455,457],{"className":219,"code":456,"language":221,"meta":83,"style":83},"npx prisma migrate dev --name init\n",[20,458,459],{"__ignoreMap":83},[87,460,461,463,465,468,471,474],{"class":89,"line":90},[87,462,251],{"class":107},[87,464,254],{"class":162},[87,466,467],{"class":162}," migrate",[87,469,470],{"class":162}," dev",[87,472,473],{"class":137}," --name",[87,475,476],{"class":162}," init\n",[15,478,480],{"id":479},"le-client-prisma-en-singleton","Le client Prisma en singleton",[11,482,483,484,487],{},"Piège classique : instancier ",[20,485,486],{},"new PrismaClient()"," dans chaque route ouvre\nune nouvelle connexion à chaque appel et épuise la base. On l'instancie une\nseule fois, dans un utilitaire serveur réutilisable :",[78,489,491],{"className":80,"code":490,"language":82,"meta":83,"style":83},"\u002F\u002F server\u002Futils\u002Fprisma.ts\nimport { PrismaClient } from '@prisma\u002Fclient'\n\nexport const prisma = new PrismaClient()\n",[20,492,493,498,512,516],{"__ignoreMap":83},[87,494,495],{"class":89,"line":90},[87,496,497],{"class":93},"\u002F\u002F server\u002Futils\u002Fprisma.ts\n",[87,499,500,503,506,509],{"class":89,"line":97},[87,501,502],{"class":100},"import",[87,504,505],{"class":111}," { PrismaClient } ",[87,507,508],{"class":100},"from",[87,510,511],{"class":162}," '@prisma\u002Fclient'\n",[87,513,514],{"class":89,"line":128},[87,515,306],{"emptyLinePlaceholder":305},[87,517,518,520,523,525,528,531,534],{"class":89,"line":153},[87,519,101],{"class":100},[87,521,522],{"class":100}," const",[87,524,254],{"class":137},[87,526,527],{"class":100}," =",[87,529,530],{"class":100}," new",[87,532,533],{"class":107}," PrismaClient",[87,535,536],{"class":111},"()\n",[11,538,539,540,543],{},"Placé dans ",[20,541,542],{},"server\u002Futils\u002F",", il est auto-importé dans toutes les routes\nserveur. Une instance, une réserve de connexions, partagée proprement.",[15,545,547],{"id":546},"une-vraie-route-qui-lit-la-base","Une vraie route qui lit la base",[78,549,551],{"className":80,"code":550,"language":82,"meta":83,"style":83},"\u002F\u002F server\u002Fapi\u002Farticles.get.ts\nexport default defineEventHandler(async () => {\n  return await prisma.article.findMany({\n    where: { publie: true },\n    orderBy: { cree_le: 'desc' }\n  })\n})\n",[20,552,553,558,578,594,605,615,620],{"__ignoreMap":83},[87,554,555],{"class":89,"line":90},[87,556,557],{"class":93},"\u002F\u002F server\u002Fapi\u002Farticles.get.ts\n",[87,559,560,562,564,566,568,571,574,576],{"class":89,"line":97},[87,561,101],{"class":100},[87,563,104],{"class":100},[87,565,108],{"class":107},[87,567,341],{"class":111},[87,569,570],{"class":100},"async",[87,572,573],{"class":111}," () ",[87,575,122],{"class":100},[87,577,125],{"class":111},[87,579,580,582,585,588,591],{"class":89,"line":128},[87,581,156],{"class":100},[87,583,584],{"class":100}," await",[87,586,587],{"class":111}," prisma.article.",[87,589,590],{"class":107},"findMany",[87,592,593],{"class":111},"({\n",[87,595,596,599,602],{"class":89,"line":153},[87,597,598],{"class":111},"    where: { publie: ",[87,600,601],{"class":137},"true",[87,603,604],{"class":111}," },\n",[87,606,607,610,613],{"class":89,"line":177},[87,608,609],{"class":111},"    orderBy: { cree_le: ",[87,611,612],{"class":162},"'desc'",[87,614,174],{"class":111},[87,616,617],{"class":89,"line":309},[87,618,619],{"class":111},"  })\n",[87,621,622],{"class":89,"line":320},[87,623,180],{"class":111},[11,625,626],{},"Et une route qui écrit, avec validation de l'entrée :",[78,628,630],{"className":80,"code":629,"language":82,"meta":83,"style":83},"\u002F\u002F server\u002Fapi\u002Farticles.post.ts\nexport default defineEventHandler(async (event) => {\n  const body = await readBody(event)\n\n  if (!body?.titre || !body?.contenu) {\n    throw createError({ statusCode: 400, message: 'Titre et contenu requis' })\n  }\n\n  return await prisma.article.create({\n    data: { titre: body.titre, contenu: body.contenu }\n  })\n})\n",[20,631,632,637,660,676,680,702,725,730,734,747,752,756],{"__ignoreMap":83},[87,633,634],{"class":89,"line":90},[87,635,636],{"class":93},"\u002F\u002F server\u002Fapi\u002Farticles.post.ts\n",[87,638,639,641,643,645,647,649,652,654,656,658],{"class":89,"line":97},[87,640,101],{"class":100},[87,642,104],{"class":100},[87,644,108],{"class":107},[87,646,341],{"class":111},[87,648,570],{"class":100},[87,650,651],{"class":111}," (",[87,653,116],{"class":115},[87,655,119],{"class":111},[87,657,122],{"class":100},[87,659,125],{"class":111},[87,661,662,664,667,669,671,674],{"class":89,"line":128},[87,663,131],{"class":100},[87,665,666],{"class":137}," body",[87,668,527],{"class":100},[87,670,584],{"class":100},[87,672,673],{"class":107}," readBody",[87,675,150],{"class":111},[87,677,678],{"class":89,"line":153},[87,679,306],{"emptyLinePlaceholder":305},[87,681,682,685,687,690,693,696,699],{"class":89,"line":177},[87,683,684],{"class":100},"  if",[87,686,651],{"class":111},[87,688,689],{"class":100},"!",[87,691,692],{"class":111},"body?.titre ",[87,694,695],{"class":100},"||",[87,697,698],{"class":100}," !",[87,700,701],{"class":111},"body?.contenu) {\n",[87,703,704,707,710,713,716,719,722],{"class":89,"line":309},[87,705,706],{"class":100},"    throw",[87,708,709],{"class":107}," createError",[87,711,712],{"class":111},"({ statusCode: ",[87,714,715],{"class":137},"400",[87,717,718],{"class":111},", message: ",[87,720,721],{"class":162},"'Titre et contenu requis'",[87,723,724],{"class":111}," })\n",[87,726,727],{"class":89,"line":320},[87,728,729],{"class":111},"  }\n",[87,731,732],{"class":89,"line":330},[87,733,306],{"emptyLinePlaceholder":305},[87,735,736,738,740,742,745],{"class":89,"line":350},[87,737,156],{"class":100},[87,739,584],{"class":100},[87,741,587],{"class":111},[87,743,744],{"class":107},"create",[87,746,593],{"class":111},[87,748,749],{"class":89,"line":355},[87,750,751],{"class":111},"    data: { titre: body.titre, contenu: body.contenu }\n",[87,753,754],{"class":89,"line":360},[87,755,619],{"class":111},[87,757,758],{"class":89,"line":371},[87,759,180],{"class":111},[11,761,762,765,766,769],{},[20,763,764],{},"createError"," renvoie une vraie erreur HTTP, que le client saura interpréter\nplutôt que de recevoir un ",[20,767,768],{},"200"," avec un objet d'erreur déguisé.",[15,771,773],{"id":772},"le-front-consomme-sa-propre-api","Le front consomme sa propre API",[11,775,776,777,780],{},"Côté page, on ne fait pas d'appel réseau exotique : la même application\nappelle sa route avec ",[20,778,779],{},"useFetch",". L'URL est relative, il n'y a pas de\nproblème de CORS, et le rendu serveur pré-remplit déjà les données.",[78,782,786],{"className":783,"code":784,"language":785,"meta":83,"style":83},"language-vue shiki shiki-themes github-dark","\u003Cscript setup lang=\"ts\">\nconst { data: articles, refresh } = await useFetch('\u002Fapi\u002Farticles')\n\nasync function publier(nouvel: { titre: string; contenu: string }) {\n  await $fetch('\u002Fapi\u002Farticles', { method: 'POST', body: nouvel })\n  await refresh()\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Carticle v-for=\"a in articles\" :key=\"a.id\">\n    \u003Ch2>{{ a.titre }}\u003C\u002Fh2>\n    \u003Cp>{{ a.contenu }}\u003C\u002Fp>\n  \u003C\u002Farticle>\n\u003C\u002Ftemplate>\n","vue",[20,787,788,811,848,852,893,914,923,927,936,940,949,975,989,1002,1011],{"__ignoreMap":83},[87,789,790,793,797,800,803,805,808],{"class":89,"line":90},[87,791,792],{"class":111},"\u003C",[87,794,796],{"class":795},"s4JwU","script",[87,798,799],{"class":107}," setup",[87,801,802],{"class":107}," lang",[87,804,144],{"class":111},[87,806,807],{"class":162},"\"ts\"",[87,809,810],{"class":111},">\n",[87,812,813,816,818,821,824,827,829,832,834,836,838,841,843,846],{"class":89,"line":97},[87,814,815],{"class":100},"const",[87,817,134],{"class":111},[87,819,820],{"class":115},"data",[87,822,823],{"class":111},": ",[87,825,826],{"class":137},"articles",[87,828,72],{"class":111},[87,830,831],{"class":137},"refresh",[87,833,141],{"class":111},[87,835,144],{"class":100},[87,837,584],{"class":100},[87,839,840],{"class":107}," useFetch",[87,842,341],{"class":111},[87,844,845],{"class":162},"'\u002Fapi\u002Farticles'",[87,847,347],{"class":111},[87,849,850],{"class":89,"line":128},[87,851,306],{"emptyLinePlaceholder":305},[87,853,854,856,859,862,864,867,870,872,875,877,880,883,886,888,890],{"class":89,"line":153},[87,855,570],{"class":100},[87,857,858],{"class":100}," function",[87,860,861],{"class":107}," publier",[87,863,341],{"class":111},[87,865,866],{"class":115},"nouvel",[87,868,869],{"class":100},":",[87,871,134],{"class":111},[87,873,874],{"class":115},"titre",[87,876,869],{"class":100},[87,878,879],{"class":137}," string",[87,881,882],{"class":111},"; ",[87,884,885],{"class":115},"contenu",[87,887,869],{"class":100},[87,889,879],{"class":137},[87,891,892],{"class":111}," }) {\n",[87,894,895,898,901,903,905,908,911],{"class":89,"line":177},[87,896,897],{"class":100},"  await",[87,899,900],{"class":107}," $fetch",[87,902,341],{"class":111},[87,904,845],{"class":162},[87,906,907],{"class":111},", { method: ",[87,909,910],{"class":162},"'POST'",[87,912,913],{"class":111},", body: nouvel })\n",[87,915,916,918,921],{"class":89,"line":309},[87,917,897],{"class":100},[87,919,920],{"class":107}," refresh",[87,922,536],{"class":111},[87,924,925],{"class":89,"line":320},[87,926,300],{"class":111},[87,928,929,932,934],{"class":89,"line":330},[87,930,931],{"class":111},"\u003C\u002F",[87,933,796],{"class":795},[87,935,810],{"class":111},[87,937,938],{"class":89,"line":350},[87,939,306],{"emptyLinePlaceholder":305},[87,941,942,944,947],{"class":89,"line":355},[87,943,792],{"class":111},[87,945,946],{"class":795},"template",[87,948,810],{"class":111},[87,950,951,954,957,960,962,965,968,970,973],{"class":89,"line":360},[87,952,953],{"class":111},"  \u003C",[87,955,956],{"class":795},"article",[87,958,959],{"class":107}," v-for",[87,961,144],{"class":111},[87,963,964],{"class":162},"\"a in articles\"",[87,966,967],{"class":107}," :key",[87,969,144],{"class":111},[87,971,972],{"class":162},"\"a.id\"",[87,974,810],{"class":111},[87,976,977,980,982,985,987],{"class":89,"line":371},[87,978,979],{"class":111},"    \u003C",[87,981,15],{"class":795},[87,983,984],{"class":111},">{{ a.titre }}\u003C\u002F",[87,986,15],{"class":795},[87,988,810],{"class":111},[87,990,991,993,995,998,1000],{"class":89,"line":394},[87,992,979],{"class":111},[87,994,11],{"class":795},[87,996,997],{"class":111},">{{ a.contenu }}\u003C\u002F",[87,999,11],{"class":795},[87,1001,810],{"class":111},[87,1003,1004,1007,1009],{"class":89,"line":403},[87,1005,1006],{"class":111},"  \u003C\u002F",[87,1008,956],{"class":795},[87,1010,810],{"class":111},[87,1012,1013,1015,1017],{"class":89,"line":411},[87,1014,931],{"class":111},[87,1016,946],{"class":795},[87,1018,810],{"class":111},[11,1020,1021,1023,1024,1027],{},[20,1022,779],{}," pour lire au rendu (avec cache et hydratation), ",[20,1025,1026],{},"$fetch"," pour les\nactions ponctuelles comme un POST. La boucle est complète : la page lit une\nroute Nitro, qui interroge Prisma, qui parle à la base, et tout est typé du\nmême bout à l'autre.",[15,1029,1031],{"id":1030},"ce-que-ça-change-au-déploiement","Ce que ça change au déploiement",[11,1033,1034],{},"Attention : dès qu'il y a des routes serveur et une base, on quitte le monde\npurement statique. Il faut une cible capable d'exécuter du Node (un VPS, une\nplateforme applicative, une fonction serverless), et non plus un simple\nhébergement de fichiers. Nitro sait produire la bonne sortie pour chacune de\nces cibles, mais le choix d'hébergement, lui, n'est plus le même que pour un\nsite statique. C'est le prix, assumé, du fullstack.",[15,1036,1038],{"id":1037},"la-leçon","La leçon",[11,1040,1041,1042,1044],{},"Nuxt fullstack, ce n'est pas « Nuxt plus un backend » : c'est un seul\nprojet, un seul langage, une seule chaîne de types, du composant Vue jusqu'à\nla table SQL. Nitro fournit le serveur, Prisma la base, et ",[20,1043,779],{}," relie\nles deux sans couture. Pour un produit mené en petite équipe, cette unité\nest un avantage décisif : moins de code de plomberie, moins de contrats\nd'API à maintenir, et le remboursement se fait sur chaque fonctionnalité.",[1046,1047,1048],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":83,"searchDepth":97,"depth":97,"links":1050},[1051,1053,1054,1055,1056,1057,1058],{"id":17,"depth":97,"text":1052},"Le dossier server\u002F",{"id":212,"depth":97,"text":213},{"id":479,"depth":97,"text":480},{"id":546,"depth":97,"text":547},{"id":772,"depth":97,"text":773},{"id":1030,"depth":97,"text":1031},{"id":1037,"depth":97,"text":1038},false,"2026-07-18","Construire l'API dans le même projet que le front : les routes serveur de Nitro, Prisma pour la base de données, et useFetch côté client. Un seul dépôt, une seule langue.","md",null,{},"\u002Fblog\u002Fapp-fullstack-nuxt-nitro-prisma",{"title":6,"description":1061},"blog\u002Fapp-fullstack-nuxt-nitro-prisma",[1069,1070,1071],"Nuxt","Prisma","Fullstack","t2G8Y9IU16PitwCOlzmh_bflZslEo8SfxVkYuzq7oA4",{"id":1074,"title":1075,"body":1076,"brouillon":1059,"date":1704,"description":1705,"extension":1062,"image":1063,"imageAlt":1063,"meta":1706,"navigation":305,"path":1707,"seo":1708,"stem":1709,"tags":1710,"__hash__":1712},"blog\u002Fblog\u002Fgestion-etat-nuxt-vs-flutter.md","Gestion d'état : Nuxt face à Flutter, le parallèle qui éclaire",{"type":8,"value":1077,"toc":1696},[1078,1081,1085,1088,1103,1133,1140,1144,1211,1261,1278,1282,1388,1392,1395,1542,1545,1661,1672,1676,1685,1688,1690,1693],[11,1079,1080],{},"Je travaille des deux côtés : des applications Flutter au long cours et des\nprojets web sous Nuxt. La bonne nouvelle pour qui connaît déjà l'un, c'est\nque les concepts de gestion d'état de l'autre ne sont pas nouveaux : ce sont\nles mêmes idées sous d'autres noms. Voici la table de correspondance que\nj'aurais aimé avoir en tête plus tôt.",[15,1082,1084],{"id":1083},"le-socle-réactivité-contre-notification","Le socle : réactivité contre notification",[11,1086,1087],{},"La différence de fond tient au modèle de réactivité.",[11,1089,1090,1091,1094,1095,1098,1099,1102],{},"En ",[191,1092,1093],{},"Vue\u002FNuxt",", la réactivité est implicite et automatique. Un ",[20,1096,1097],{},"ref"," est\nune valeur observable, et tout ce qui la lit se réabonne tout seul. Vous\nchangez ",[20,1100,1101],{},"compteur.value",", l'interface se met à jour, sans rien déclarer.",[11,1104,1090,1105,1108,1109,1112,1113,1116,1117,1120,1121,1124,1125,1128,1129,1132],{},[191,1106,1107],{},"Flutter",", la réactivité est explicite. Un ",[20,1110,1111],{},"ValueNotifier"," détient une\nvaleur, mais il faut appeler ",[20,1114,1115],{},"notifyListeners()"," (ou assigner ",[20,1118,1119],{},".value",") pour\nprévenir, et un widget doit s'abonner explicitement (via ",[20,1122,1123],{},"ValueListenableBuilder",",\n",[20,1126,1127],{},"ListenableBuilder",", ou le ",[20,1130,1131],{},"watch"," de Riverpod).",[11,1134,1135,1136,1139],{},"Retenez cette phrase : ",[191,1137,1138],{},"Vue observe, Flutter notifie",". Tout le reste en\ndécoule.",[15,1141,1143],{"id":1142},"la-brique-de-base","La brique de base",[78,1145,1147],{"className":80,"code":1146,"language":82,"meta":83,"style":83},"\u002F\u002F Nuxt \u002F Vue\nconst compteur = ref(0)\nconst doubler = computed(() => compteur.value * 2)\ncompteur.value++   \u002F\u002F l'UI qui lit compteur se met à jour\n",[20,1148,1149,1154,1173,1201],{"__ignoreMap":83},[87,1150,1151],{"class":89,"line":90},[87,1152,1153],{"class":93},"\u002F\u002F Nuxt \u002F Vue\n",[87,1155,1156,1158,1161,1163,1166,1168,1171],{"class":89,"line":97},[87,1157,815],{"class":100},[87,1159,1160],{"class":137}," compteur",[87,1162,527],{"class":100},[87,1164,1165],{"class":107}," ref",[87,1167,341],{"class":111},[87,1169,1170],{"class":137},"0",[87,1172,347],{"class":111},[87,1174,1175,1177,1180,1182,1185,1188,1190,1193,1196,1199],{"class":89,"line":128},[87,1176,815],{"class":100},[87,1178,1179],{"class":137}," doubler",[87,1181,527],{"class":100},[87,1183,1184],{"class":107}," computed",[87,1186,1187],{"class":111},"(() ",[87,1189,122],{"class":100},[87,1191,1192],{"class":111}," compteur.value ",[87,1194,1195],{"class":100},"*",[87,1197,1198],{"class":137}," 2",[87,1200,347],{"class":111},[87,1202,1203,1205,1208],{"class":89,"line":153},[87,1204,1101],{"class":111},[87,1206,1207],{"class":100},"++",[87,1209,1210],{"class":93},"   \u002F\u002F l'UI qui lit compteur se met à jour\n",[78,1212,1216],{"className":1213,"code":1214,"language":1215,"meta":83,"style":83},"language-dart shiki shiki-themes github-dark","\u002F\u002F Flutter\nfinal compteur = ValueNotifier\u003Cint>(0);\ncompteur.value++;  \u002F\u002F les ValueListenableBuilder abonnés se reconstruisent\n","dart",[20,1217,1218,1223,1249],{"__ignoreMap":83},[87,1219,1220],{"class":89,"line":90},[87,1221,1222],{"class":93},"\u002F\u002F Flutter\n",[87,1224,1225,1228,1231,1233,1236,1238,1241,1244,1246],{"class":89,"line":97},[87,1226,1227],{"class":100},"final",[87,1229,1230],{"class":111}," compteur ",[87,1232,144],{"class":100},[87,1234,1235],{"class":137}," ValueNotifier",[87,1237,792],{"class":111},[87,1239,1240],{"class":137},"int",[87,1242,1243],{"class":111},">(",[87,1245,1170],{"class":137},[87,1247,1248],{"class":111},");\n",[87,1250,1251,1253,1255,1258],{"class":89,"line":128},[87,1252,1101],{"class":111},[87,1254,1207],{"class":100},[87,1256,1257],{"class":111},";  ",[87,1259,1260],{"class":93},"\u002F\u002F les ValueListenableBuilder abonnés se reconstruisent\n",[11,1262,1263,1264,1266,1267,1269,1270,1273,1274,1277],{},"Le ",[20,1265,1097],{}," de Vue correspond au ",[20,1268,1111],{}," de Flutter : une valeur\nobservable. Le ",[20,1271,1272],{},"computed"," de Vue, une valeur dérivée qui se recalcule seule,\ncorrespond à un ",[20,1275,1276],{},"Provider"," dérivé de Riverpod ou à un getter recalculé. Dans\nles deux mondes, on ne recopie jamais une valeur dérivée à la main : on la\ndéclare comme dépendant d'une autre.",[15,1279,1281],{"id":1280},"la-table-de-correspondance","La table de correspondance",[38,1283,1284,1304,1319,1339,1357,1371],{},[41,1285,1286,1294,1295,1303],{},[191,1287,1288,1290,1291],{},[20,1289,1097],{}," \u002F ",[20,1292,1293],{},"reactive"," (Vue) correspond à ",[191,1296,1297,1299,1300],{},[20,1298,1111],{}," \u002F\n",[20,1301,1302],{},"ChangeNotifier"," (Flutter) : la valeur observable de base.",[41,1305,1306,1310,1311,1314,1315,1318],{},[191,1307,1308],{},[20,1309,1272],{}," correspond à un ",[191,1312,1313],{},"provider dérivé"," (Riverpod) ou un\n",[191,1316,1317],{},"getter"," recalculé : l'état dérivé.",[41,1320,1321,1328,1329,1334,1335,1338],{},[191,1322,1323,1290,1325],{},[20,1324,1131],{},[20,1326,1327],{},"watchEffect"," correspond à ",[191,1330,1331],{},[20,1332,1333],{},"ref.listen"," (Riverpod) ou à\nl'écoute d'un ",[20,1336,1337],{},"Listenable"," : réagir à un changement pour déclencher un\neffet.",[41,1340,1341,1346,1347,1352,1353,1356],{},[191,1342,1343],{},[20,1344,1345],{},"useState"," (état partagé Nuxt) correspond à un ",[191,1348,1349,1351],{},[20,1350,1276],{}," global","\n(Riverpod) ou à un ",[20,1354,1355],{},"ChangeNotifierProvider"," (Provider) : un état accessible\ndepuis plusieurs endroits.",[41,1358,1359,1362,1363,1366,1367,1370],{},[191,1360,1361],{},"Pinia"," (store avec state, actions, getters) correspond à ",[191,1364,1365],{},"Riverpod\nstructuré"," ou à un ensemble de ",[191,1368,1369],{},"Cubit\u002FBLoC"," : le domaine métier avec sa\nlogique.",[41,1372,1373,1266,1382,1387],{},[191,1374,1375,1376,1290,1379],{},"L'injection par ",[20,1377,1378],{},"provide",[20,1380,1381],{},"inject",[191,1383,1384,1386],{},[20,1385,1276],{},"\nd'arbre de widgets"," de Flutter : fournir une dépendance à un sous-arbre.",[15,1389,1391],{"id":1390},"un-store-deux-écritures","Un store, deux écritures",[11,1393,1394],{},"Un panier, côté Pinia :",[78,1396,1398],{"className":80,"code":1397,"language":82,"meta":83,"style":83},"\u002F\u002F stores\u002Fpanier.ts\nexport const usePanier = defineStore('panier', () => {\n  const articles = ref\u003CArticle[]>([])\n  const total = computed(() =>\n    articles.value.reduce((s, a) => s + a.prix, 0)\n  )\n  function ajouter(a: Article) { articles.value.push(a) }\n  return { articles, total, ajouter }\n})\n",[20,1399,1400,1405,1431,1450,1466,1501,1506,1531,1538],{"__ignoreMap":83},[87,1401,1402],{"class":89,"line":90},[87,1403,1404],{"class":93},"\u002F\u002F stores\u002Fpanier.ts\n",[87,1406,1407,1409,1411,1414,1416,1419,1421,1424,1427,1429],{"class":89,"line":97},[87,1408,101],{"class":100},[87,1410,522],{"class":100},[87,1412,1413],{"class":137}," usePanier",[87,1415,527],{"class":100},[87,1417,1418],{"class":107}," defineStore",[87,1420,341],{"class":111},[87,1422,1423],{"class":162},"'panier'",[87,1425,1426],{"class":111},", () ",[87,1428,122],{"class":100},[87,1430,125],{"class":111},[87,1432,1433,1435,1438,1440,1442,1444,1447],{"class":89,"line":128},[87,1434,131],{"class":100},[87,1436,1437],{"class":137}," articles",[87,1439,527],{"class":100},[87,1441,1165],{"class":107},[87,1443,792],{"class":111},[87,1445,1446],{"class":107},"Article",[87,1448,1449],{"class":111},"[]>([])\n",[87,1451,1452,1454,1457,1459,1461,1463],{"class":89,"line":153},[87,1453,131],{"class":100},[87,1455,1456],{"class":137}," total",[87,1458,527],{"class":100},[87,1460,1184],{"class":107},[87,1462,1187],{"class":111},[87,1464,1465],{"class":100},"=>\n",[87,1467,1468,1471,1474,1476,1479,1481,1484,1486,1488,1491,1494,1497,1499],{"class":89,"line":177},[87,1469,1470],{"class":111},"    articles.value.",[87,1472,1473],{"class":107},"reduce",[87,1475,112],{"class":111},[87,1477,1478],{"class":115},"s",[87,1480,72],{"class":111},[87,1482,1483],{"class":115},"a",[87,1485,119],{"class":111},[87,1487,122],{"class":100},[87,1489,1490],{"class":111}," s ",[87,1492,1493],{"class":100},"+",[87,1495,1496],{"class":111}," a.prix, ",[87,1498,1170],{"class":137},[87,1500,347],{"class":111},[87,1502,1503],{"class":89,"line":309},[87,1504,1505],{"class":111},"  )\n",[87,1507,1508,1511,1514,1516,1518,1520,1522,1525,1528],{"class":89,"line":320},[87,1509,1510],{"class":100},"  function",[87,1512,1513],{"class":107}," ajouter",[87,1515,341],{"class":111},[87,1517,1483],{"class":115},[87,1519,869],{"class":100},[87,1521,366],{"class":107},[87,1523,1524],{"class":111},") { articles.value.",[87,1526,1527],{"class":107},"push",[87,1529,1530],{"class":111},"(a) }\n",[87,1532,1533,1535],{"class":89,"line":330},[87,1534,156],{"class":100},[87,1536,1537],{"class":111}," { articles, total, ajouter }\n",[87,1539,1540],{"class":89,"line":350},[87,1541,180],{"class":111},[11,1543,1544],{},"Le même panier, côté Cubit (flutter_bloc) :",[78,1546,1548],{"className":1213,"code":1547,"language":1215,"meta":83,"style":83},"class PanierCubit extends Cubit\u003CList\u003CArticle>> {\n  PanierCubit() : super([]);\n\n  int get total => state.fold(0, (s, a) => s + a.prix);\n\n  void ajouter(Article a) => emit([...state, a]);\n}\n",[20,1549,1550,1576,1592,1596,1631,1635,1657],{"__ignoreMap":83},[87,1551,1552,1555,1558,1561,1564,1566,1569,1571,1573],{"class":89,"line":90},[87,1553,1554],{"class":100},"class",[87,1556,1557],{"class":137}," PanierCubit",[87,1559,1560],{"class":100}," extends",[87,1562,1563],{"class":137}," Cubit",[87,1565,792],{"class":111},[87,1567,1568],{"class":137},"List",[87,1570,792],{"class":111},[87,1572,1446],{"class":137},[87,1574,1575],{"class":111},">> {\n",[87,1577,1578,1581,1584,1586,1589],{"class":89,"line":97},[87,1579,1580],{"class":137},"  PanierCubit",[87,1582,1583],{"class":111},"() ",[87,1585,869],{"class":100},[87,1587,1588],{"class":137}," super",[87,1590,1591],{"class":111},"([]);\n",[87,1593,1594],{"class":89,"line":128},[87,1595,306],{"emptyLinePlaceholder":305},[87,1597,1598,1601,1604,1607,1609,1612,1615,1617,1619,1622,1624,1626,1628],{"class":89,"line":153},[87,1599,1600],{"class":137},"  int",[87,1602,1603],{"class":100}," get",[87,1605,1606],{"class":111}," total ",[87,1608,122],{"class":100},[87,1610,1611],{"class":111}," state.",[87,1613,1614],{"class":107},"fold",[87,1616,341],{"class":111},[87,1618,1170],{"class":137},[87,1620,1621],{"class":111},", (s, a) ",[87,1623,122],{"class":100},[87,1625,1490],{"class":111},[87,1627,1493],{"class":100},[87,1629,1630],{"class":111}," a.prix);\n",[87,1632,1633],{"class":89,"line":177},[87,1634,306],{"emptyLinePlaceholder":305},[87,1636,1637,1640,1642,1644,1646,1649,1651,1654],{"class":89,"line":309},[87,1638,1639],{"class":100},"  void",[87,1641,1513],{"class":107},[87,1643,341],{"class":111},[87,1645,1446],{"class":137},[87,1647,1648],{"class":111}," a) ",[87,1650,122],{"class":100},[87,1652,1653],{"class":107}," emit",[87,1655,1656],{"class":111},"([...state, a]);\n",[87,1658,1659],{"class":89,"line":320},[87,1660,300],{"class":111},[11,1662,1663,1664,1667,1668,1671],{},"La forme diffère (Pinia expose un objet réactif, le Cubit émet un nouvel\nétat immuable), mais la structure est identique : un état, une valeur\ndérivée (",[20,1665,1666],{},"total","), une action (",[20,1669,1670],{},"ajouter","). Un développeur Flutter lit le\nstore Pinia sans effort, et réciproquement.",[15,1673,1675],{"id":1674},"la-dimension-que-flutter-na-pas-le-serveur","La dimension que Flutter n'a pas : le serveur",[11,1677,1678,1679,1681,1682,1684],{},"Il y a un endroit où le parallèle s'arrête, et c'est important. Nuxt fait du\nrendu serveur : le même code de gestion d'état s'exécute côté serveur puis\ncôté client. D'où la nécessité de ",[20,1680,1345],{}," plutôt qu'un simple ",[20,1683,1097],{},"\nglobal, pour isoler l'état par requête et éviter qu'un visiteur voie les\ndonnées d'un autre (j'en parle dans l'article sur les bonnes pratiques).",[11,1686,1687],{},"Flutter n'a pas ce problème : une application tourne sur un seul appareil,\npour un seul utilisateur. Un singleton d'état y est parfaitement légitime.\nEn Nuxt, le même singleton est un bug de sécurité. C'est la principale\nadaptation mentale à faire en passant de Flutter au web rendu côté serveur.",[15,1689,1038],{"id":1037},[11,1691,1692],{},"Ne réapprenez pas la gestion d'état, traduisez-la. « Observer une valeur »,\n« en dériver une autre », « réagir à un changement », « partager un\ndomaine » : ces quatre gestes existent des deux côtés, avec des noms\ndifférents. Gardez en tête que Vue observe quand Flutter notifie, et\nn'oubliez jamais la dimension serveur de Nuxt, absente de Flutter. Avec ces\ndeux repères, passer d'un monde à l'autre devient une simple traduction, pas\nun nouvel apprentissage.",[1046,1694,1695],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":83,"searchDepth":97,"depth":97,"links":1697},[1698,1699,1700,1701,1702,1703],{"id":1083,"depth":97,"text":1084},{"id":1142,"depth":97,"text":1143},{"id":1280,"depth":97,"text":1281},{"id":1390,"depth":97,"text":1391},{"id":1674,"depth":97,"text":1675},{"id":1037,"depth":97,"text":1038},"2026-07-16","Pour un développeur Flutter qui apprend Nuxt (ou l'inverse) : la correspondance entre ref, computed, useState, Pinia et ValueNotifier, Provider, Riverpod, Bloc.",{},"\u002Fblog\u002Fgestion-etat-nuxt-vs-flutter",{"title":1075,"description":1705},"blog\u002Fgestion-etat-nuxt-vs-flutter",[1069,1107,1711],"Gestion d'état","scuvq3Ifi-uadXy9ziFqGIkbxMqEKu9B-vLJ-MLkUsU",{"id":1714,"title":1715,"body":1716,"brouillon":1059,"date":2090,"description":2091,"extension":1062,"image":1063,"imageAlt":1063,"meta":2092,"navigation":305,"path":2093,"seo":2094,"stem":2095,"tags":2096,"__hash__":2097},"blog\u002Fblog\u002Fgestion-etat-nuxt-bonnes-pratiques.md","Gestion d'état dans Nuxt : useState, bonnes pratiques et pièges",{"type":8,"value":1717,"toc":2079},[1718,1724,1731,1773,1786,1792,1802,1849,1860,1867,1877,1893,1958,1964,1968,1977,1995,2005,2009,2062,2064,2076],[11,1719,1720,1721,1723],{},"En Vue côté client, on partage souvent un état en déclarant un ",[20,1722,1097],{}," dans un\nmodule et en l'important où on veut. Ça marche très bien dans le navigateur,\net ça casse silencieusement dès qu'on passe au rendu serveur. Comprendre\npourquoi, c'est comprendre toute la gestion d'état de Nuxt.",[15,1725,1727,1728,1730],{"id":1726},"le-piège-du-ref-partagé-en-module","Le piège du ",[20,1729,1097],{}," partagé en module",[78,1732,1734],{"className":80,"code":1733,"language":82,"meta":83,"style":83},"\u002F\u002F state.ts  - À NE PAS FAIRE en SSR\nimport { ref } from 'vue'\nexport const utilisateur = ref(null)\n",[20,1735,1736,1741,1753],{"__ignoreMap":83},[87,1737,1738],{"class":89,"line":90},[87,1739,1740],{"class":93},"\u002F\u002F state.ts  - À NE PAS FAIRE en SSR\n",[87,1742,1743,1745,1748,1750],{"class":89,"line":97},[87,1744,502],{"class":100},[87,1746,1747],{"class":111}," { ref } ",[87,1749,508],{"class":100},[87,1751,1752],{"class":162}," 'vue'\n",[87,1754,1755,1757,1759,1762,1764,1766,1768,1771],{"class":89,"line":128},[87,1756,101],{"class":100},[87,1758,522],{"class":100},[87,1760,1761],{"class":137}," utilisateur",[87,1763,527],{"class":100},[87,1765,1165],{"class":107},[87,1767,341],{"class":111},[87,1769,1770],{"class":137},"null",[87,1772,347],{"class":111},[11,1774,1775,1776,1778,1779,1782,1783,1785],{},"Côté client, ce ",[20,1777,1097],{}," est unique : un seul navigateur, un seul état. Côté\nserveur, le processus Node est ",[191,1780,1781],{},"partagé entre tous les visiteurs",". Ce\n",[20,1784,1097],{}," unique devient alors un état commun à toutes les requêtes\nsimultanées : l'utilisateur A voit les données de l'utilisateur B. C'est une\nfuite de données inter-requêtes, et elle est très difficile à repérer parce\nqu'elle n'apparaît pas en développement local avec un seul onglet.",[15,1787,1789,1791],{"id":1788},"usestate-létat-partagé-fait-pour-le-ssr",[20,1790,1345],{}," : l'état partagé fait pour le SSR",[11,1793,1794,1795,1797,1798,1801],{},"La réponse de Nuxt est ",[20,1796,1345],{},". Il crée un état réactif ",[191,1799,1800],{},"isolé par\nrequête"," côté serveur, et correctement transféré (sérialisé puis hydraté)\nvers le client :",[78,1803,1805],{"className":80,"code":1804,"language":82,"meta":83,"style":83},"\u002F\u002F app\u002Fcomposables\u002FuseUtilisateur.ts\nexport function useUtilisateur() {\n  return useState('utilisateur', () => null)\n}\n",[20,1806,1807,1812,1824,1845],{"__ignoreMap":83},[87,1808,1809],{"class":89,"line":90},[87,1810,1811],{"class":93},"\u002F\u002F app\u002Fcomposables\u002FuseUtilisateur.ts\n",[87,1813,1814,1816,1818,1821],{"class":89,"line":97},[87,1815,101],{"class":100},[87,1817,858],{"class":100},[87,1819,1820],{"class":107}," useUtilisateur",[87,1822,1823],{"class":111},"() {\n",[87,1825,1826,1828,1831,1833,1836,1838,1840,1843],{"class":89,"line":128},[87,1827,156],{"class":100},[87,1829,1830],{"class":107}," useState",[87,1832,341],{"class":111},[87,1834,1835],{"class":162},"'utilisateur'",[87,1837,1426],{"class":111},[87,1839,122],{"class":100},[87,1841,1842],{"class":137}," null",[87,1844,347],{"class":111},[87,1846,1847],{"class":89,"line":153},[87,1848,300],{"class":111},[11,1850,1851,1852,1855,1856,1859],{},"Deux détails comptent. Le ",[191,1853,1854],{},"premier argument est une clé"," unique : deux\nappels avec la même clé partagent le même état, c'est le mécanisme de\npartage. Le ",[191,1857,1858],{},"second argument est une fonction"," d'initialisation, pas une\nvaleur directe : elle n'est appelée qu'une fois, au bon moment du cycle SSR.",[11,1861,1862,1863,1866],{},"L'envelopper dans un composable ",[20,1864,1865],{},"useUtilisateur()"," évite de répéter la clé\npartout et centralise le point d'accès. Si un jour la clé change, un seul\nfichier bouge.",[15,1868,1870,1872,1873,1876],{"id":1869},"usefetch-et-useasyncdata-létat-cest-aussi-les-données",[20,1871,779],{}," et ",[20,1874,1875],{},"useAsyncData"," : l'état, c'est aussi les données",[11,1878,1879,1880,1882,1883,1882,1886,1889,1890,1892],{},"La majorité de l'état d'une application, ce sont des données distantes. Ne\nles gérez pas à la main avec un ",[20,1881,1097],{}," plus un ",[20,1884,1885],{},"onMounted",[20,1887,1888],{},"try\u002Fcatch",".\n",[20,1891,779],{}," fait tout cela, et de façon compatible SSR :",[78,1894,1896],{"className":783,"code":1895,"language":785,"meta":83,"style":83},"\u003Cscript setup lang=\"ts\">\nconst { data: articles, status, error } = await useFetch('\u002Fapi\u002Farticles')\n\u003C\u002Fscript>\n",[20,1897,1898,1914,1950],{"__ignoreMap":83},[87,1899,1900,1902,1904,1906,1908,1910,1912],{"class":89,"line":90},[87,1901,792],{"class":111},[87,1903,796],{"class":795},[87,1905,799],{"class":107},[87,1907,802],{"class":107},[87,1909,144],{"class":111},[87,1911,807],{"class":162},[87,1913,810],{"class":111},[87,1915,1916,1918,1920,1922,1924,1926,1928,1931,1933,1936,1938,1940,1942,1944,1946,1948],{"class":89,"line":97},[87,1917,815],{"class":100},[87,1919,134],{"class":111},[87,1921,820],{"class":115},[87,1923,823],{"class":111},[87,1925,826],{"class":137},[87,1927,72],{"class":111},[87,1929,1930],{"class":137},"status",[87,1932,72],{"class":111},[87,1934,1935],{"class":137},"error",[87,1937,141],{"class":111},[87,1939,144],{"class":100},[87,1941,584],{"class":100},[87,1943,840],{"class":107},[87,1945,341],{"class":111},[87,1947,845],{"class":162},[87,1949,347],{"class":111},[87,1951,1952,1954,1956],{"class":89,"line":128},[87,1953,931],{"class":111},[87,1955,796],{"class":795},[87,1957,810],{"class":111},[11,1959,1960,1961,1963],{},"Il récupère les données côté serveur au premier rendu (donc le HTML arrive\ndéjà rempli, bon pour le SEO), les transfère au client sans refetch, et\ngère le cache et la déduplication via une clé. Deux composants qui appellent\n",[20,1962,779],{}," avec la même URL ne déclenchent qu'une seule requête. Traiter les\ndonnées comme un état géré par le framework, plutôt que comme du code\nimpératif, élimine une classe entière de bugs.",[15,1965,1967],{"id":1966},"quand-sortir-pinia","Quand sortir Pinia",[11,1969,1970,1972,1973,1976],{},[20,1971,1345],{}," couvre l'état simple et partagé. Passez à Pinia (le store\nofficiel de l'écosystème Vue) quand l'état devient un ",[191,1974,1975],{},"domaine"," à part\nentière :",[38,1978,1979,1982,1989,1992],{},[41,1980,1981],{},"de la logique métier autour de l'état (actions, règles, validations) et\npas seulement une valeur ;",[41,1983,1984,1985,1988],{},"des valeurs dérivées nombreuses (des ",[20,1986,1987],{},"getters"," qui se recalculent) ;",[41,1990,1991],{},"un besoin de structurer par modules (panier, session, préférences) avec\nchacun son état, ses actions, ses getters ;",[41,1993,1994],{},"l'envie d'outils de débogage dédiés et d'un point d'entrée testable\nisolément.",[11,1996,1997,1998,2001,2002,2004],{},"Le critère n'est pas la taille de l'application mais la ",[191,1999,2000],{},"nature de\nl'état"," : une poignée de valeurs partagées, ",[20,2003,1345],{}," suffit ; un domaine\navec de la logique, Pinia paie son coût.",[15,2006,2008],{"id":2007},"quelques-pièges-concrets","Quelques pièges concrets",[38,2010,2011,2024,2042],{},[41,2012,2013,2019,2020,2023],{},[191,2014,2015,2016,2018],{},"Ne mettez pas de valeur non sérialisable dans ",[20,2017,1345],{},"."," L'état\ntransite du serveur au client en JSON. Une instance de classe, une\nfonction ou une ",[20,2021,2022],{},"Date"," ne survivent pas au voyage. Stockez des données\nsimples et reconstruisez les objets riches côté client si besoin.",[41,2025,2026,2029,2030,2033,2034,72,2036,1124,2039,2018],{},[191,2027,2028],{},"Attention aux clés qui se chevauchent."," Deux ",[20,2031,2032],{},"useState('data', ...)","\ndans deux contextes différents partagent le même état par accident.\nNommez les clés comme un espace de noms : ",[20,2035,1423],{},[20,2037,2038],{},"'session-user'",[20,2040,2041],{},"'prefs-theme'",[41,2043,2044,2055,2056,2059,2060,2018],{},[191,2045,2046,2047,2050,2051,2054],{},"N'accédez pas à ",[20,2048,2049],{},"window"," ou ",[20,2052,2053],{},"localStorage"," à l'initialisation."," Ce code\ns'exécute aussi côté serveur, où ces objets n'existent pas. Pour un état\npersistant côté navigateur, ",[20,2057,2058],{},"useCookie"," fonctionne des deux côtés ; sinon,\ngardez l'accès au stockage dans ",[20,2061,1885],{},[15,2063,1038],{"id":1037},[11,2065,2066,2067,2069,2070,2072,2073,2075],{},"La gestion d'état en Nuxt se résume à une prise de conscience : votre code\ntourne côté serveur autant que côté client, et un état mal isolé fuit entre\nles requêtes. À partir de là, tout s'ordonne : ",[20,2068,1345],{}," pour l'état\npartagé simple, ",[20,2071,779],{}," pour les données distantes, Pinia quand l'état\ndevient un domaine. Le ",[20,2074,1097],{}," global de module, lui, reste dans les projets\npurement client. Choisissez l'outil selon la portée de l'état, pas selon\nl'habitude.",[1046,2077,2078],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":83,"searchDepth":97,"depth":97,"links":2080},[2081,2083,2085,2087,2088,2089],{"id":1726,"depth":97,"text":2082},"Le piège du ref partagé en module",{"id":1788,"depth":97,"text":2084},"useState : l'état partagé fait pour le SSR",{"id":1869,"depth":97,"text":2086},"useFetch et useAsyncData : l'état, c'est aussi les données",{"id":1966,"depth":97,"text":1967},{"id":2007,"depth":97,"text":2008},{"id":1037,"depth":97,"text":1038},"2026-07-14","useState, partage compatible SSR, pollution inter-requêtes, quand sortir Pinia : les règles pour gérer l'état d'une application Nuxt sans se tirer une balle dans le pied.",{},"\u002Fblog\u002Fgestion-etat-nuxt-bonnes-pratiques",{"title":1715,"description":2091},"blog\u002Fgestion-etat-nuxt-bonnes-pratiques",[1069,1711],"ihNvcDqGs6Bw_Po635w67cxCJfCqxO-qiJgBxrfmGII",{"id":2099,"title":2100,"body":2101,"brouillon":1059,"date":2577,"description":2578,"extension":1062,"image":1063,"imageAlt":1063,"meta":2579,"navigation":305,"path":2580,"seo":2581,"stem":2582,"tags":2583,"__hash__":2586},"blog\u002Fblog\u002Fcomposables-nuxt-quand-pourquoi.md","Les composables en Nuxt : c'est quoi, quand et dans quels cas",{"type":8,"value":2102,"toc":2569},[2103,2106,2110,2134,2145,2294,2301,2362,2366,2369,2417,2420,2424,2427,2433,2457,2482,2486,2489,2527,2531,2541,2561,2563,2566],[11,2104,2105],{},"« Composable » est l'un de ces mots qui semblent évidents jusqu'au moment où\nil faut décider si un bout de code en mérite un. On finit par en créer trop\npeu (et copier-coller de la logique), ou trop (et transformer chaque\nfonction en composable inutilement). Voici ma façon de trancher.",[15,2107,2109],{"id":2108},"ce-quest-vraiment-un-composable","Ce qu'est vraiment un composable",[11,2111,2112,2113,2116,2117,2120,2121,72,2123,72,2125,2127,2128,72,2130,2133],{},"Un composable est une fonction, par convention préfixée par ",[20,2114,2115],{},"use",", qui\nencapsule de la ",[191,2118,2119],{},"logique réactive"," en s'appuyant sur le système de\nréactivité de Vue (",[20,2122,1097],{},[20,2124,1272],{},[20,2126,1131],{},") et parfois sur le cycle de\nvie des composants (",[20,2129,1885],{},[20,2131,2132],{},"onBeforeUnmount",").",[11,2135,2136,2137,2140,2141,2144],{},"La distinction clé est là : une fonction utilitaire classique transforme des\nentrées en sorties et n'a pas de mémoire (",[20,2138,2139],{},"formatDate(date)","). Un composable,\nlui, ",[191,2142,2143],{},"détient un état qui vit dans le temps"," et réagit aux changements.",[78,2146,2148],{"className":80,"code":2147,"language":82,"meta":83,"style":83},"\u002F\u002F app\u002Fcomposables\u002FuseCompteur.ts\nexport function useCompteur(pasInitial = 1) {\n  const valeur = ref(0)\n  const pas = ref(pasInitial)\n\n  const doubler = computed(() => valeur.value * 2)\n  const incrementer = () => { valeur.value += pas.value }\n  const reinitialiser = () => { valeur.value = 0 }\n\n  return { valeur, doubler, incrementer, reinitialiser }\n}\n",[20,2149,2150,2155,2177,2194,2208,2212,2235,2257,2279,2283,2290],{"__ignoreMap":83},[87,2151,2152],{"class":89,"line":90},[87,2153,2154],{"class":93},"\u002F\u002F app\u002Fcomposables\u002FuseCompteur.ts\n",[87,2156,2157,2159,2161,2164,2166,2169,2171,2174],{"class":89,"line":97},[87,2158,101],{"class":100},[87,2160,858],{"class":100},[87,2162,2163],{"class":107}," useCompteur",[87,2165,341],{"class":111},[87,2167,2168],{"class":115},"pasInitial",[87,2170,527],{"class":100},[87,2172,2173],{"class":137}," 1",[87,2175,2176],{"class":111},") {\n",[87,2178,2179,2181,2184,2186,2188,2190,2192],{"class":89,"line":128},[87,2180,131],{"class":100},[87,2182,2183],{"class":137}," valeur",[87,2185,527],{"class":100},[87,2187,1165],{"class":107},[87,2189,341],{"class":111},[87,2191,1170],{"class":137},[87,2193,347],{"class":111},[87,2195,2196,2198,2201,2203,2205],{"class":89,"line":153},[87,2197,131],{"class":100},[87,2199,2200],{"class":137}," pas",[87,2202,527],{"class":100},[87,2204,1165],{"class":107},[87,2206,2207],{"class":111},"(pasInitial)\n",[87,2209,2210],{"class":89,"line":177},[87,2211,306],{"emptyLinePlaceholder":305},[87,2213,2214,2216,2218,2220,2222,2224,2226,2229,2231,2233],{"class":89,"line":309},[87,2215,131],{"class":100},[87,2217,1179],{"class":137},[87,2219,527],{"class":100},[87,2221,1184],{"class":107},[87,2223,1187],{"class":111},[87,2225,122],{"class":100},[87,2227,2228],{"class":111}," valeur.value ",[87,2230,1195],{"class":100},[87,2232,1198],{"class":137},[87,2234,347],{"class":111},[87,2236,2237,2239,2242,2244,2246,2248,2251,2254],{"class":89,"line":320},[87,2238,131],{"class":100},[87,2240,2241],{"class":107}," incrementer",[87,2243,527],{"class":100},[87,2245,573],{"class":111},[87,2247,122],{"class":100},[87,2249,2250],{"class":111}," { valeur.value ",[87,2252,2253],{"class":100},"+=",[87,2255,2256],{"class":111}," pas.value }\n",[87,2258,2259,2261,2264,2266,2268,2270,2272,2274,2277],{"class":89,"line":330},[87,2260,131],{"class":100},[87,2262,2263],{"class":107}," reinitialiser",[87,2265,527],{"class":100},[87,2267,573],{"class":111},[87,2269,122],{"class":100},[87,2271,2250],{"class":111},[87,2273,144],{"class":100},[87,2275,2276],{"class":137}," 0",[87,2278,174],{"class":111},[87,2280,2281],{"class":89,"line":350},[87,2282,306],{"emptyLinePlaceholder":305},[87,2284,2285,2287],{"class":89,"line":355},[87,2286,156],{"class":100},[87,2288,2289],{"class":111}," { valeur, doubler, incrementer, reinitialiser }\n",[87,2291,2292],{"class":89,"line":360},[87,2293,300],{"class":111},[11,2295,2296,2297,2300],{},"Rangé dans ",[20,2298,2299],{},"app\u002Fcomposables\u002F",", il est auto-importé partout, sans ligne\nd'import :",[78,2302,2304],{"className":783,"code":2303,"language":785,"meta":83,"style":83},"\u003Cscript setup lang=\"ts\">\nconst { valeur, doubler, incrementer } = useCompteur(5)\n\u003C\u002Fscript>\n",[20,2305,2306,2322,2354],{"__ignoreMap":83},[87,2307,2308,2310,2312,2314,2316,2318,2320],{"class":89,"line":90},[87,2309,792],{"class":111},[87,2311,796],{"class":795},[87,2313,799],{"class":107},[87,2315,802],{"class":107},[87,2317,144],{"class":111},[87,2319,807],{"class":162},[87,2321,810],{"class":111},[87,2323,2324,2326,2328,2331,2333,2336,2338,2341,2343,2345,2347,2349,2352],{"class":89,"line":97},[87,2325,815],{"class":100},[87,2327,134],{"class":111},[87,2329,2330],{"class":137},"valeur",[87,2332,72],{"class":111},[87,2334,2335],{"class":137},"doubler",[87,2337,72],{"class":111},[87,2339,2340],{"class":137},"incrementer",[87,2342,141],{"class":111},[87,2344,144],{"class":100},[87,2346,2163],{"class":107},[87,2348,341],{"class":111},[87,2350,2351],{"class":137},"5",[87,2353,347],{"class":111},[87,2355,2356,2358,2360],{"class":89,"line":128},[87,2357,931],{"class":111},[87,2359,796],{"class":795},[87,2361,810],{"class":111},[15,2363,2365],{"id":2364},"les-composables-que-nuxt-vous-donne-déjà","Les composables que Nuxt vous donne déjà",[11,2367,2368],{},"Avant d'écrire le vôtre, sachez que Nuxt en fournit une famille très\nutilisée. Les connaître évite de réinventer :",[38,2370,2371,2378,2389,2402,2410],{},[41,2372,2373,2377],{},[191,2374,2375],{},[20,2376,1345],{}," : un état réactif partagé et compatible SSR (voir l'article\ndédié à la gestion d'état).",[41,2379,2380,1872,2384,2388],{},[191,2381,2382],{},[20,2383,779],{},[191,2385,2386],{},[20,2387,1875],{}," : récupérer des données avec cache,\ndéduplication et gestion des états de chargement.",[41,2390,2391,1872,2396,2401],{},[191,2392,2393],{},[20,2394,2395],{},"useRoute",[191,2397,2398],{},[20,2399,2400],{},"useRouter"," : lire la route courante et naviguer.",[41,2403,2404,2409],{},[191,2405,2406],{},[20,2407,2408],{},"useRuntimeConfig"," : accéder à la configuration exposée à l'exécution.",[41,2411,2412,2416],{},[191,2413,2414],{},[20,2415,2058],{}," : lire et écrire un cookie de façon réactive.",[11,2418,2419],{},"Beaucoup de besoins sont déjà couverts. Le composable maison arrive quand\naucun de ceux-là ne répond exactement à votre cas.",[15,2421,2423],{"id":2422},"quand-écrire-un-composable","Quand écrire un composable",[11,2425,2426],{},"Trois signaux clairs :",[11,2428,2429,2432],{},[191,2430,2431],{},"1. La même logique réactive apparaît dans plusieurs composants."," Deux\npages qui gèrent un formulaire avec les mêmes règles de validation, deux\nécrans qui écoutent la même source de données : extrayez la logique dans un\ncomposable et les deux la partagent sans duplication.",[11,2434,2435,2438,2439,2442,2443,2446,2447,72,2450,72,2453,2456],{},[191,2436,2437],{},"2. Un composant devient gros à cause de sa logique, pas de son template.","\nSi votre ",[20,2440,2441],{},"\u003Cscript setup>"," dépasse largement votre ",[20,2444,2445],{},"\u003Ctemplate>",", c'est souvent\nque plusieurs préoccupations cohabitent. Les découper en composables\n(",[20,2448,2449],{},"useFiltres",[20,2451,2452],{},"usePagination",[20,2454,2455],{},"useSelection",") rend chacune testable et\nnommée.",[11,2458,2459,2462,2463,2466,2467,2470,2471,2474,2475,2477,2478,2481],{},[191,2460,2461],{},"3. Vous manipulez une API du navigateur avec du cycle de vie."," Un\nécouteur d'événement, un ",[20,2464,2465],{},"IntersectionObserver",", un ",[20,2468,2469],{},"matchMedia"," : tout ce\nqui doit s'installer au montage et se nettoyer à la destruction est un\ncandidat naturel. Le composable ",[20,2472,2473],{},"useReveal"," de ce site, par exemple,\nencapsule un ",[20,2476,2465],{}," et son nettoyage : le composant appelant\nignore tout de cette mécanique, il appelle ",[20,2479,2480],{},"useReveal()"," et c'est réglé.",[15,2483,2485],{"id":2484},"quand-ne-pas-en-écrire-un","Quand ne PAS en écrire un",[11,2487,2488],{},"Le réflexe inverse est tout aussi important.",[38,2490,2491,2512,2521],{},[41,2492,2493,2496,2497,2500,2501,2504,2505,2508,2509,2511],{},[191,2494,2495],{},"Une transformation pure, sans état"," : c'est un utilitaire, pas un\ncomposable. ",[20,2498,2499],{},"formatPrix(montant)"," va dans ",[20,2502,2503],{},"utils\u002F",", pas dans\n",[20,2506,2507],{},"composables\u002F",". Le préfixe ",[20,2510,2115],{}," sur une fonction sans réactivité est un\nfaux signal qui trompe le lecteur.",[41,2513,2514,2517,2518,2018],{},[191,2515,2516],{},"Un besoin de structure visuelle réutilisable"," : c'est un composant, pas\nun composable. Si vous voulez réutiliser du markup, écrivez un ",[20,2519,2520],{},".vue",[41,2522,2523,2526],{},[191,2524,2525],{},"De la logique utilisée à un seul endroit et qui ne grossit pas"," :\nlaissez-la dans le composant. Extraire par principe, alors qu'il n'y a ni\nduplication ni complexité, ajoute un fichier et un niveau d'indirection\npour rien.",[15,2528,2530],{"id":2529},"les-deux-règles-à-ne-pas-violer","Les deux règles à ne pas violer",[11,2532,2533,2534,2540],{},"Un composable doit ",[191,2535,2536,2537],{},"s'appeler au niveau supérieur du ",[20,2538,2539],{},"setup",", jamais dans\nune condition ou une boucle, exactement comme les hooks. C'est ce qui permet\nà Vue d'associer correctement l'état réactif au composant.",[11,2542,2543,2544,2550,2551,2553,2554,2556,2557,2560],{},"Et il doit ",[191,2545,2546,2547,2549],{},"retourner ses ",[20,2548,1097],{}," sans les déballer",". Retournez ",[20,2552,2330],{}," (le\n",[20,2555,1097],{},"), pas ",[20,2558,2559],{},"valeur.value"," : sinon le composant appelant reçoit une valeur\nfigée, déconnectée de la réactivité. C'est l'erreur de débutant la plus\nfréquente sur les composables.",[15,2562,1038],{"id":1037},[11,2564,2565],{},"La bonne question n'est pas « est-ce que je peux en faire un composable »\n(on peut presque toujours), mais « est-ce que ce code détient de l'état\nréactif, et est-ce que je le réutilise ou le sépare pour de bonnes\nraisons ». État réactif plus réutilisation ou clarté : composable. Simple\ntransformation : utilitaire. Structure visuelle : composant. Avec ces trois\ncases, on range juste, et le projet reste lisible.",[1046,2567,2568],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":83,"searchDepth":97,"depth":97,"links":2570},[2571,2572,2573,2574,2575,2576],{"id":2108,"depth":97,"text":2109},{"id":2364,"depth":97,"text":2365},{"id":2422,"depth":97,"text":2423},{"id":2484,"depth":97,"text":2485},{"id":2529,"depth":97,"text":2530},{"id":1037,"depth":97,"text":1038},"2026-07-12","Un composable n'est pas juste une fonction rangée dans un dossier : c'est le bon endroit pour la logique réactive réutilisable. Quand en écrire un, et quand s'en abstenir.",{},"\u002Fblog\u002Fcomposables-nuxt-quand-pourquoi",{"title":2100,"description":2578},"blog\u002Fcomposables-nuxt-quand-pourquoi",[1069,2584,2585],"Composables","Vue","O4wyp0cmi-N7cc0_fb-cXsUpIRcDcZKIc7Xzhrz0TnQ",{"id":2588,"title":2589,"body":2590,"brouillon":1059,"date":3098,"description":3099,"extension":1062,"image":1063,"imageAlt":1063,"meta":3100,"navigation":305,"path":3101,"seo":3102,"stem":3103,"tags":3104,"__hash__":3106},"blog\u002Fblog\u002Fcomprendre-architecture-nuxt-4.md","Comprendre l'architecture de Nuxt 4 : la carte du framework",{"type":8,"value":2591,"toc":3089},[2592,2595,2599,2605,2691,2701,2705,2726,2836,2852,2856,2866,2902,2909,2913,2916,2954,3046,3049,3053,3062,3066,3081,3083,3086],[11,2593,2594],{},"Quand on arrive sur Nuxt en venant de Vue « nu », la première impression est\ndéroutante : beaucoup de choses semblent se produire toutes seules. Les\ncomposants s'importent sans qu'on les importe, une page apparaît dès qu'on\ncrée un fichier, une fonction serveur devient une URL. Ce n'est pas de la\nmagie, c'est une convention. Une fois la carte du framework en tête, Nuxt\ndevient très prévisible. Voici cette carte.",[15,2596,2598],{"id":2597},"la-structure-de-dossiers","La structure de dossiers",[11,2600,2601,2602,2604],{},"En Nuxt 4, le code applicatif vit dans ",[20,2603,31],{},", et le reste du projet\ns'organise autour. Chaque dossier a un rôle précis et une convention\nassociée :",[38,2606,2607,2618,2626,2633,2641,2649,2657,2665,2675,2683],{},[41,2608,2609,2614,2615,2617],{},[191,2610,2611],{},[20,2612,2613],{},"app\u002Fpages\u002F"," : chaque fichier ",[20,2616,2520],{}," devient une route. C'est le\nrouting par fichiers (voir plus bas).",[41,2619,2620,2625],{},[191,2621,2622],{},[20,2623,2624],{},"app\u002Fcomponents\u002F"," : les composants réutilisables, auto-importés dans\ntout le projet.",[41,2627,2628,2632],{},[191,2629,2630],{},[20,2631,2299],{}," : la logique réactive réutilisable, elle aussi\nauto-importée.",[41,2634,2635,2640],{},[191,2636,2637],{},[20,2638,2639],{},"app\u002Flayouts\u002F"," : les gabarits qui enveloppent les pages (en-tête,\npied de page, structure commune).",[41,2642,2643,2648],{},[191,2644,2645],{},[20,2646,2647],{},"app\u002Fplugins\u002F"," : le code qui s'exécute à l'initialisation de\nl'application, côté client, serveur ou les deux.",[41,2650,2651,2656],{},[191,2652,2653],{},[20,2654,2655],{},"app\u002Fmiddleware\u002F"," : les gardes de navigation, exécutés avant d'entrer\nsur une route (redirections, contrôle d'accès).",[41,2658,2659,2664],{},[191,2660,2661],{},[20,2662,2663],{},"app\u002Fassets\u002F"," : les ressources traitées par le build (CSS, images\noptimisées, polices).",[41,2666,2667,2671,2672,2674],{},[191,2668,2669],{},[20,2670,22],{}," : le code serveur, à la racine du projet et non dans\n",[20,2673,31],{},", car il ne tourne jamais dans le navigateur.",[41,2676,2677,2682],{},[191,2678,2679],{},[20,2680,2681],{},"public\u002F"," : les fichiers servis tels quels, sans transformation\n(favicon, robots.txt, images fixes).",[41,2684,2685,2690],{},[191,2686,2687],{},[20,2688,2689],{},"nuxt.config.ts"," : le point de contrôle unique de toute la\nconfiguration.",[11,2692,2693,2694,2697,2698,2700],{},"La règle mentale est simple : ",[191,2695,2696],{},"le nom du dossier décrit son rôle, et le\nrôle impose la convention",". Vous ne choisissez pas où mettre un composable,\nvous le posez dans ",[20,2699,2507],{}," et Nuxt fait le reste.",[15,2702,2704],{"id":2703},"les-auto-imports","Les auto-imports",[11,2706,2707,2708,2711,2712,1872,2714,2716,2717,72,2719,72,2721,72,2723,2725],{},"C'est la fonctionnalité qui déroute le plus au début. Les composants de\n",[20,2709,2710],{},"components\u002F",", les fonctions de ",[20,2713,2507],{},[20,2715,2503],{},", ainsi que les\nutilitaires du framework (",[20,2718,1097],{},[20,2720,1272],{},[20,2722,2395],{},[20,2724,779],{},") sont\ndisponibles partout sans ligne d'import.",[78,2727,2729],{"className":783,"code":2728,"language":785,"meta":83,"style":83},"\u003Cscript setup lang=\"ts\">\n\u002F\u002F Aucun import : ref, useRoute et MonComposant sont auto-importés\nconst route = useRoute()\nconst compteur = ref(0)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CMonComposant :id=\"route.params.id\" @clic=\"compteur++\" \u002F>\n\u003C\u002Ftemplate>\n",[20,2730,2731,2747,2752,2766,2782,2790,2794,2802,2828],{"__ignoreMap":83},[87,2732,2733,2735,2737,2739,2741,2743,2745],{"class":89,"line":90},[87,2734,792],{"class":111},[87,2736,796],{"class":795},[87,2738,799],{"class":107},[87,2740,802],{"class":107},[87,2742,144],{"class":111},[87,2744,807],{"class":162},[87,2746,810],{"class":111},[87,2748,2749],{"class":89,"line":97},[87,2750,2751],{"class":93},"\u002F\u002F Aucun import : ref, useRoute et MonComposant sont auto-importés\n",[87,2753,2754,2756,2759,2761,2764],{"class":89,"line":128},[87,2755,815],{"class":100},[87,2757,2758],{"class":137}," route",[87,2760,527],{"class":100},[87,2762,2763],{"class":107}," useRoute",[87,2765,536],{"class":111},[87,2767,2768,2770,2772,2774,2776,2778,2780],{"class":89,"line":153},[87,2769,815],{"class":100},[87,2771,1160],{"class":137},[87,2773,527],{"class":100},[87,2775,1165],{"class":107},[87,2777,341],{"class":111},[87,2779,1170],{"class":137},[87,2781,347],{"class":111},[87,2783,2784,2786,2788],{"class":89,"line":177},[87,2785,931],{"class":111},[87,2787,796],{"class":795},[87,2789,810],{"class":111},[87,2791,2792],{"class":89,"line":309},[87,2793,306],{"emptyLinePlaceholder":305},[87,2795,2796,2798,2800],{"class":89,"line":320},[87,2797,792],{"class":111},[87,2799,946],{"class":795},[87,2801,810],{"class":111},[87,2803,2804,2806,2809,2812,2814,2817,2820,2822,2825],{"class":89,"line":330},[87,2805,953],{"class":111},[87,2807,2808],{"class":795},"MonComposant",[87,2810,2811],{"class":107}," :id",[87,2813,144],{"class":111},[87,2815,2816],{"class":162},"\"route.params.id\"",[87,2818,2819],{"class":107}," @clic",[87,2821,144],{"class":111},[87,2823,2824],{"class":162},"\"compteur++\"",[87,2826,2827],{"class":111}," \u002F>\n",[87,2829,2830,2832,2834],{"class":89,"line":350},[87,2831,931],{"class":111},[87,2833,946],{"class":795},[87,2835,810],{"class":111},[11,2837,2838,2839,2842,2843,72,2845,2847,2848,2851],{},"Ce n'est pas de la magie opaque : Nuxt génère des déclarations de types dans\n",[20,2840,2841],{},".nuxt\u002F"," pour que l'éditeur et TypeScript connaissent ces symboles. Le\nbénéfice est un code débarrassé de vingt lignes d'imports en haut de chaque\nfichier ; le prix est qu'il faut connaître la convention pour savoir d'où\nvient une fonction. La règle : si vous ne voyez pas l'import, cherchez dans\n",[20,2844,2507],{},[20,2846,2710],{}," ou la documentation des ",[20,2849,2850],{},"use*"," de Nuxt.",[15,2853,2855],{"id":2854},"le-routing-par-fichiers","Le routing par fichiers",[11,2857,2858,2859,2861,2862,2865],{},"Il n'y a pas de fichier de routes à maintenir. L'arborescence de\n",[20,2860,2613],{}," ",[191,2863,2864],{},"est"," la table de routage :",[38,2867,2868,2877,2885,2894],{},[41,2869,2870,2873,2874],{},[20,2871,2872],{},"pages\u002Findex.vue"," donne ",[20,2875,2876],{},"\u002F",[41,2878,2879,2873,2882],{},[20,2880,2881],{},"pages\u002Fblog\u002Findex.vue",[20,2883,2884],{},"\u002Fblog",[41,2886,2887,2873,2890,2893],{},[20,2888,2889],{},"pages\u002Fblog\u002F[slug].vue",[20,2891,2892],{},"\u002Fblog\u002Fmon-article"," (segment dynamique)",[41,2895,2896,2873,2899],{},[20,2897,2898],{},"pages\u002Fprojets\u002F[slug].vue",[20,2900,2901],{},"\u002Fprojets\u002Fnom-du-projet",[11,2903,2904,2905,2908],{},"Le paramètre dynamique se lit avec ",[20,2906,2907],{},"useRoute().params.slug",". Ajouter une\npage, c'est ajouter un fichier ; supprimer une page, c'est supprimer un\nfichier. Le routeur suit toujours le disque.",[15,2910,2912],{"id":2911},"les-modes-de-rendu","Les modes de rendu",[11,2914,2915],{},"C'est la décision d'architecture la plus structurante, et Nuxt la rend\nsouple. Le même code peut être servi de plusieurs façons :",[38,2917,2918,2924,2934,2940],{},[41,2919,2920,2923],{},[191,2921,2922],{},"SSR"," (rendu serveur) : le HTML est généré à la demande sur le serveur,\npuis hydraté côté client. Idéal pour du contenu dynamique et le SEO.",[41,2925,2926,2929,2930,2933],{},[191,2927,2928],{},"SSG"," (génération statique, ",[20,2931,2932],{},"nuxi generate",") : tout le site est\npré-rendu en HTML au build, puis servi comme des fichiers. Idéal pour un\nsite vitrine ou un blog. C'est le mode de ce site.",[41,2935,2936,2939],{},[191,2937,2938],{},"SPA"," : rendu uniquement côté client, pour une application derrière une\nauthentification où le SEO n'importe pas.",[41,2941,2942,2945,2946,2949,2950,2953],{},[191,2943,2944],{},"Hybride"," : via ",[20,2947,2948],{},"routeRules"," dans ",[20,2951,2952],{},"nuxt.config",", on choisit le mode\nroute par route (une partie statique, une partie serveur, une partie en\ncache).",[78,2955,2957],{"className":80,"code":2956,"language":82,"meta":83,"style":83},"export default defineNuxtConfig({\n  routeRules: {\n    '\u002F': { prerender: true },          \u002F\u002F page d'accueil figée au build\n    '\u002Fblog\u002F**': { prerender: true },   \u002F\u002F articles statiques\n    '\u002Fapi\u002F**': { cors: true },         \u002F\u002F routes serveur dynamiques\n    '\u002Fadmin\u002F**': { ssr: false }        \u002F\u002F espace privé en SPA\n  }\n})\n",[20,2958,2959,2970,2975,2991,3006,3022,3038,3042],{"__ignoreMap":83},[87,2960,2961,2963,2965,2968],{"class":89,"line":90},[87,2962,101],{"class":100},[87,2964,104],{"class":100},[87,2966,2967],{"class":107}," defineNuxtConfig",[87,2969,593],{"class":111},[87,2971,2972],{"class":89,"line":97},[87,2973,2974],{"class":111},"  routeRules: {\n",[87,2976,2977,2980,2983,2985,2988],{"class":89,"line":128},[87,2978,2979],{"class":162},"    '\u002F'",[87,2981,2982],{"class":111},": { prerender: ",[87,2984,601],{"class":137},[87,2986,2987],{"class":111}," },          ",[87,2989,2990],{"class":93},"\u002F\u002F page d'accueil figée au build\n",[87,2992,2993,2996,2998,3000,3003],{"class":89,"line":153},[87,2994,2995],{"class":162},"    '\u002Fblog\u002F**'",[87,2997,2982],{"class":111},[87,2999,601],{"class":137},[87,3001,3002],{"class":111}," },   ",[87,3004,3005],{"class":93},"\u002F\u002F articles statiques\n",[87,3007,3008,3011,3014,3016,3019],{"class":89,"line":177},[87,3009,3010],{"class":162},"    '\u002Fapi\u002F**'",[87,3012,3013],{"class":111},": { cors: ",[87,3015,601],{"class":137},[87,3017,3018],{"class":111}," },         ",[87,3020,3021],{"class":93},"\u002F\u002F routes serveur dynamiques\n",[87,3023,3024,3027,3030,3032,3035],{"class":89,"line":309},[87,3025,3026],{"class":162},"    '\u002Fadmin\u002F**'",[87,3028,3029],{"class":111},": { ssr: ",[87,3031,425],{"class":137},[87,3033,3034],{"class":111}," }        ",[87,3036,3037],{"class":93},"\u002F\u002F espace privé en SPA\n",[87,3039,3040],{"class":89,"line":320},[87,3041,729],{"class":111},[87,3043,3044],{"class":89,"line":330},[87,3045,180],{"class":111},[11,3047,3048],{},"Ne choisissez pas le mode par habitude : choisissez-le par besoin réel de\nchaque partie du site.",[15,3050,3052],{"id":3051},"nitro-le-serveur-universel","Nitro : le serveur universel",[11,3054,3055,3056,3058,3059,3061],{},"Sous Nuxt, il y a Nitro, le moteur serveur. C'est lui qui exécute le SSR,\nqui sert le dossier ",[20,3057,22],{},", et surtout qui permet de déployer le même\nprojet sur des cibles très différentes (Node, un hébergeur statique, un\nworker edge) sans changer le code. Quand vous écrivez une route dans\n",[20,3060,35],{},", c'est Nitro qui la transforme en point d'accès HTTP. J'y\nreviens en détail dans l'article sur les applications fullstack.",[15,3063,3065],{"id":3064},"les-modules","Les modules",[11,3067,3068,3069,3072,3073,3076,3077,3080],{},"Un module Nuxt est une extension qui s'installe et se configure en une\nligne, et qui apporte ses propres conventions. Ce site en utilise plusieurs :\n",[20,3070,3071],{},"@nuxt\u002Fcontent"," pour écrire les articles en Markdown, ",[20,3074,3075],{},"@nuxtjs\u002Fi18n"," pour le\nbilinguisme, ",[20,3078,3079],{},"@nuxtjs\u002Fseo"," pour les métadonnées et le sitemap. Un bon\nréflexe avant de coder une fonctionnalité transverse : vérifier s'il existe\ndéjà un module qui la fournit avec ses conventions éprouvées.",[15,3082,1038],{"id":1037},[11,3084,3085],{},"Nuxt n'est pas Vue « avec des trucs en plus » : c'est un cadre de\nconventions qui répond à une seule question, « où va chaque chose ». Une\nfois la carte assimilée (un dossier par rôle, l'auto-import, le routing par\nfichiers, le mode de rendu choisi par besoin, Nitro derrière), le framework\ncesse de surprendre et devient un accélérateur. Le temps passé à comprendre\ncette structure au départ est remboursé sur chaque fonctionnalité ensuite.",[1046,3087,3088],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":83,"searchDepth":97,"depth":97,"links":3090},[3091,3092,3093,3094,3095,3096,3097],{"id":2597,"depth":97,"text":2598},{"id":2703,"depth":97,"text":2704},{"id":2854,"depth":97,"text":2855},{"id":2911,"depth":97,"text":2912},{"id":3051,"depth":97,"text":3052},{"id":3064,"depth":97,"text":3065},{"id":1037,"depth":97,"text":1038},"2026-07-10","Dossiers, auto-imports, routing, modes de rendu, Nitro : une vue d'ensemble structurée de Nuxt 4 pour savoir où va chaque chose et pourquoi.",{},"\u002Fblog\u002Fcomprendre-architecture-nuxt-4",{"title":2589,"description":3099},"blog\u002Fcomprendre-architecture-nuxt-4",[1069,3105],"Architecture","PP9oRb5oRzRzA476-ltAOe3MAAh0PJXL6C4Ay2S4YhQ",{"id":3108,"title":3109,"body":3110,"brouillon":1059,"date":3594,"description":3595,"extension":1062,"image":1063,"imageAlt":1063,"meta":3596,"navigation":305,"path":3597,"seo":3598,"stem":3599,"tags":3600,"__hash__":3604},"blog\u002Fblog\u002Ftester-une-api-nestjs-jest-supertest.md","Tester une API NestJS : Jest, mocks et tests e2e avec Supertest",{"type":8,"value":3111,"toc":3588},[3112,3115,3119,3126,3410,3425,3428,3446,3450,3453,3547,3550,3554,3580,3582,3585],[11,3113,3114],{},"Le système d'injection de dépendances de NestJS n'est pas qu'une élégance\nd'architecture : c'est ce qui rend le backend profondément testable. Chaque\ndépendance déclarée dans un constructeur est un point de substitution pour\nles tests. Voici comment j'organise la stratégie, du test unitaire au test\nend-to-end.",[15,3116,3118],{"id":3117},"tests-unitaires-le-module-de-test-et-les-mocks","Tests unitaires : le module de test et les mocks",[11,3120,3121,3122,3125],{},"NestJS fournit ",[20,3123,3124],{},"Test.createTestingModule",", qui reconstruit un mini-conteneur\nd'injection où chaque provider peut être remplacé :",[78,3127,3129],{"className":80,"code":3128,"language":82,"meta":83,"style":83},"describe('DocumentsService', () => {\n  let service: DocumentsService;\n  const prisma = {\n    document: { findMany: jest.fn(), create: jest.fn() },\n  };\n\n  beforeEach(async () => {\n    const module = await Test.createTestingModule({\n      providers: [\n        DocumentsService,\n        { provide: PrismaService, useValue: prisma },\n      ],\n    }).compile();\n\n    service = module.get(DocumentsService);\n    jest.clearAllMocks();\n  });\n\n  it('refuse l\\'accès à un document d\\'un autre patient', async () => {\n    prisma.document.findMany.mockResolvedValue([]);\n\n    await expect(\n      service.getForPatient('doc-1', 'patient-B'),\n    ).rejects.toThrow(ForbiddenException);\n  });\n});\n",[20,3130,3131,3147,3163,3173,3189,3194,3198,3213,3233,3238,3243,3248,3253,3264,3268,3285,3295,3300,3305,3337,3348,3353,3365,3387,3399,3404],{"__ignoreMap":83},[87,3132,3133,3136,3138,3141,3143,3145],{"class":89,"line":90},[87,3134,3135],{"class":107},"describe",[87,3137,341],{"class":111},[87,3139,3140],{"class":162},"'DocumentsService'",[87,3142,1426],{"class":111},[87,3144,122],{"class":100},[87,3146,125],{"class":111},[87,3148,3149,3152,3155,3157,3160],{"class":89,"line":97},[87,3150,3151],{"class":100},"  let",[87,3153,3154],{"class":111}," service",[87,3156,869],{"class":100},[87,3158,3159],{"class":107}," DocumentsService",[87,3161,3162],{"class":111},";\n",[87,3164,3165,3167,3169,3171],{"class":89,"line":128},[87,3166,131],{"class":100},[87,3168,254],{"class":137},[87,3170,527],{"class":100},[87,3172,125],{"class":111},[87,3174,3175,3178,3181,3184,3186],{"class":89,"line":153},[87,3176,3177],{"class":111},"    document: { findMany: jest.",[87,3179,3180],{"class":107},"fn",[87,3182,3183],{"class":111},"(), create: jest.",[87,3185,3180],{"class":107},[87,3187,3188],{"class":111},"() },\n",[87,3190,3191],{"class":89,"line":177},[87,3192,3193],{"class":111},"  };\n",[87,3195,3196],{"class":89,"line":309},[87,3197,306],{"emptyLinePlaceholder":305},[87,3199,3200,3203,3205,3207,3209,3211],{"class":89,"line":320},[87,3201,3202],{"class":107},"  beforeEach",[87,3204,341],{"class":111},[87,3206,570],{"class":100},[87,3208,573],{"class":111},[87,3210,122],{"class":100},[87,3212,125],{"class":111},[87,3214,3215,3218,3221,3223,3225,3228,3231],{"class":89,"line":330},[87,3216,3217],{"class":100},"    const",[87,3219,3220],{"class":137}," module",[87,3222,527],{"class":100},[87,3224,584],{"class":100},[87,3226,3227],{"class":111}," Test.",[87,3229,3230],{"class":107},"createTestingModule",[87,3232,593],{"class":111},[87,3234,3235],{"class":89,"line":350},[87,3236,3237],{"class":111},"      providers: [\n",[87,3239,3240],{"class":89,"line":355},[87,3241,3242],{"class":111},"        DocumentsService,\n",[87,3244,3245],{"class":89,"line":360},[87,3246,3247],{"class":111},"        { provide: PrismaService, useValue: prisma },\n",[87,3249,3250],{"class":89,"line":371},[87,3251,3252],{"class":111},"      ],\n",[87,3254,3255,3258,3261],{"class":89,"line":394},[87,3256,3257],{"class":111},"    }).",[87,3259,3260],{"class":107},"compile",[87,3262,3263],{"class":111},"();\n",[87,3265,3266],{"class":89,"line":403},[87,3267,306],{"emptyLinePlaceholder":305},[87,3269,3270,3273,3275,3277,3279,3282],{"class":89,"line":411},[87,3271,3272],{"class":111},"    service ",[87,3274,144],{"class":100},[87,3276,3220],{"class":137},[87,3278,2018],{"class":111},[87,3280,3281],{"class":107},"get",[87,3283,3284],{"class":111},"(DocumentsService);\n",[87,3286,3287,3290,3293],{"class":89,"line":430},[87,3288,3289],{"class":111},"    jest.",[87,3291,3292],{"class":107},"clearAllMocks",[87,3294,3263],{"class":111},[87,3296,3297],{"class":89,"line":448},[87,3298,3299],{"class":111},"  });\n",[87,3301,3303],{"class":89,"line":3302},18,[87,3304,306],{"emptyLinePlaceholder":305},[87,3306,3308,3311,3313,3316,3319,3322,3324,3327,3329,3331,3333,3335],{"class":89,"line":3307},19,[87,3309,3310],{"class":107},"  it",[87,3312,341],{"class":111},[87,3314,3315],{"class":162},"'refuse l",[87,3317,3318],{"class":137},"\\'",[87,3320,3321],{"class":162},"accès à un document d",[87,3323,3318],{"class":137},[87,3325,3326],{"class":162},"un autre patient'",[87,3328,72],{"class":111},[87,3330,570],{"class":100},[87,3332,573],{"class":111},[87,3334,122],{"class":100},[87,3336,125],{"class":111},[87,3338,3340,3343,3346],{"class":89,"line":3339},20,[87,3341,3342],{"class":111},"    prisma.document.findMany.",[87,3344,3345],{"class":107},"mockResolvedValue",[87,3347,1591],{"class":111},[87,3349,3351],{"class":89,"line":3350},21,[87,3352,306],{"emptyLinePlaceholder":305},[87,3354,3356,3359,3362],{"class":89,"line":3355},22,[87,3357,3358],{"class":100},"    await",[87,3360,3361],{"class":107}," expect",[87,3363,3364],{"class":111},"(\n",[87,3366,3368,3371,3374,3376,3379,3381,3384],{"class":89,"line":3367},23,[87,3369,3370],{"class":111},"      service.",[87,3372,3373],{"class":107},"getForPatient",[87,3375,341],{"class":111},[87,3377,3378],{"class":162},"'doc-1'",[87,3380,72],{"class":111},[87,3382,3383],{"class":162},"'patient-B'",[87,3385,3386],{"class":111},"),\n",[87,3388,3390,3393,3396],{"class":89,"line":3389},24,[87,3391,3392],{"class":111},"    ).rejects.",[87,3394,3395],{"class":107},"toThrow",[87,3397,3398],{"class":111},"(ForbiddenException);\n",[87,3400,3402],{"class":89,"line":3401},25,[87,3403,3299],{"class":111},[87,3405,3407],{"class":89,"line":3406},26,[87,3408,3409],{"class":111},"});\n",[11,3411,3412,3413,3416,3417,3420,3421,3424],{},"Le pattern clé : ",[191,3414,3415],{},"mocker à la frontière"," (Prisma, services externes,\nfiles d'attente), tester la logique réelle du service. Pas besoin de\nmockito ici : les ",[20,3418,3419],{},"jest.fn()"," natifs suffisent, et ",[20,3422,3423],{},"useValue"," les injecte à\nla place des vraies dépendances.",[11,3426,3427],{},"Deux conseils issus de l'expérience :",[38,3429,3430,3436],{},[41,3431,3432,3435],{},[191,3433,3434],{},"Testez les cas d'interdiction autant que les cas de succès."," Sur une\nAPI qui manipule des données sensibles, les tests les plus précieux sont\nceux qui vérifient qu'un utilisateur ne peut PAS accéder à ce qui ne lui\nappartient pas : guards, ownership, scopes.",[41,3437,3438,3441,3442,3445],{},[191,3439,3440],{},"Ne testez pas le framework."," Vérifier qu'un décorateur ",[20,3443,3444],{},"@Get()"," route\nbien une requête, c'est tester NestJS, pas votre code. Votre logique de\nservice et vos règles d'accès, elles, méritent chaque test.",[15,3447,3449],{"id":3448},"tests-e2e-supertest-contre-lapplication-réelle","Tests e2e : Supertest contre l'application réelle",[11,3451,3452],{},"Le test end-to-end monte l'application complète (guards, pipes,\ninterceptors, validation) et l'attaque en HTTP via Supertest :",[78,3454,3456],{"className":80,"code":3455,"language":82,"meta":83,"style":83},"it('POST \u002Fauth\u002Fverify-otp → 401 sur code invalide', async () => {\n  await request(app.getHttpServer())\n    .post('\u002Fauth\u002Fverify-otp')\n    .send({ phone: '+242066000000', code: '000000' })\n    .expect(401);\n});\n",[20,3457,3458,3478,3493,3508,3529,3543],{"__ignoreMap":83},[87,3459,3460,3463,3465,3468,3470,3472,3474,3476],{"class":89,"line":90},[87,3461,3462],{"class":107},"it",[87,3464,341],{"class":111},[87,3466,3467],{"class":162},"'POST \u002Fauth\u002Fverify-otp → 401 sur code invalide'",[87,3469,72],{"class":111},[87,3471,570],{"class":100},[87,3473,573],{"class":111},[87,3475,122],{"class":100},[87,3477,125],{"class":111},[87,3479,3480,3482,3485,3488,3491],{"class":89,"line":97},[87,3481,897],{"class":100},[87,3483,3484],{"class":107}," request",[87,3486,3487],{"class":111},"(app.",[87,3489,3490],{"class":107},"getHttpServer",[87,3492,391],{"class":111},[87,3494,3495,3498,3501,3503,3506],{"class":89,"line":128},[87,3496,3497],{"class":111},"    .",[87,3499,3500],{"class":107},"post",[87,3502,341],{"class":111},[87,3504,3505],{"class":162},"'\u002Fauth\u002Fverify-otp'",[87,3507,347],{"class":111},[87,3509,3510,3512,3515,3518,3521,3524,3527],{"class":89,"line":153},[87,3511,3497],{"class":111},[87,3513,3514],{"class":107},"send",[87,3516,3517],{"class":111},"({ phone: ",[87,3519,3520],{"class":162},"'+242066000000'",[87,3522,3523],{"class":111},", code: ",[87,3525,3526],{"class":162},"'000000'",[87,3528,724],{"class":111},[87,3530,3531,3533,3536,3538,3541],{"class":89,"line":177},[87,3532,3497],{"class":111},[87,3534,3535],{"class":107},"expect",[87,3537,341],{"class":111},[87,3539,3540],{"class":137},"401",[87,3542,1248],{"class":111},[87,3544,3545],{"class":89,"line":309},[87,3546,3409],{"class":111},[11,3548,3549],{},"C'est ici qu'on attrape ce que les tests unitaires ne voient pas : un pipe\nde validation mal configuré, un guard oublié sur une route, un DTO qui\nlaisse passer un champ inattendu. Pour la base de données, deux écoles :\nune base de test dédiée (fidélité maximale, mes préférences pour les flux\ncritiques) ou le mock du service de données (rapidité). L'important est de\nchoisir consciemment par type de test, pas par accident.",[15,3551,3553],{"id":3552},"lorganisation-qui-tient-dans-le-temps","L'organisation qui tient dans le temps",[38,3555,3556,3566,3577],{},[41,3557,3558,3561,3562,3565],{},[20,3559,3560],{},"*.spec.ts"," à côté du code pour l'unitaire, ",[20,3563,3564],{},"test\u002F*.e2e-spec.ts"," pour le\ne2e : la convention NestJS, autant la suivre.",[41,3567,3568,3569,3572,3573,3576],{},"Un ",[20,3570,3571],{},"beforeEach"," qui nettoie les mocks (",[20,3574,3575],{},"jest.clearAllMocks",") : les tests\nqui dépendent de l'ordre d'exécution sont une dette qui explose toujours.",[41,3578,3579],{},"Les tests e2e couvrent les parcours (inscription → OTP → accès protégé),\npas chaque endpoint isolément.",[15,3581,1038],{"id":1037},[11,3583,3584],{},"La testabilité d'un backend NestJS se décide dans l'architecture : si tout\npasse par l'injection, tout se substitue, donc tout se teste. Le jour où un\ntest est difficile à écrire, ce n'est presque jamais un problème de test,\nc'est le code qui révèle un couplage à corriger.",[1046,3586,3587],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":83,"searchDepth":97,"depth":97,"links":3589},[3590,3591,3592,3593],{"id":3117,"depth":97,"text":3118},{"id":3448,"depth":97,"text":3449},{"id":3552,"depth":97,"text":3553},{"id":1037,"depth":97,"text":1038},"2026-07-07","Tests unitaires de services, mock des dépendances via le système d'injection, et tests end-to-end : la stratégie de test d'un backend NestJS.",{},"\u002Fblog\u002Ftester-une-api-nestjs-jest-supertest",{"title":3109,"description":3595},"blog\u002Ftester-une-api-nestjs-jest-supertest",[3601,3602,3603],"NestJS","Tests","Backend","BKlPAf-y5M4ogYdKhRadlK9s2mwk7uVA_0vQVi9ADjA",{"id":3606,"title":3607,"body":3608,"brouillon":1059,"date":3903,"description":3904,"extension":1062,"image":1063,"imageAlt":1063,"meta":3905,"navigation":305,"path":3906,"seo":3907,"stem":3908,"tags":3909,"__hash__":3912},"blog\u002Fblog\u002Fdeployer-nuxt-statique-o2switch.md","Déployer un site Nuxt 4 statique sur o2switch",{"type":8,"value":3609,"toc":3893},[3610,3617,3621,3627,3653,3657,3662,3677,3684,3688,3699,3715,3719,3726,3869,3873,3880,3884,3887,3890],[11,3611,3612,3613,3616],{},"o2switch est un hébergeur mutualisé cPanel très répandu dans l'écosystème\nfrancophone. Il n'est pas conçu pour les frameworks JavaScript modernes,\nmais un site Nuxt ",[191,3614,3615],{},"généré en statique"," s'y déploie très simplement : sans\nNode.js côté serveur, sans Passenger, sans maintenance.",[15,3618,3620],{"id":3619},"pourquoi-le-statique","Pourquoi le statique",[11,3622,3623,3624,3626],{},"Pour un site vitrine ou un blog, la génération statique (",[20,3625,2932],{},")\nproduit du HTML pré-rendu pour chaque page. Les bénéfices sont immédiats :",[38,3628,3629,3635,3641,3647],{},[41,3630,3631,3634],{},[191,3632,3633],{},"SEO"," : Google reçoit du HTML complet, indexable instantanément.",[41,3636,3637,3640],{},[191,3638,3639],{},"Performance"," : pas de rendu serveur, tout est servi comme des fichiers.",[41,3642,3643,3646],{},[191,3644,3645],{},"Sécurité"," : aucune surface d'attaque applicative, pas de base de\ndonnées, pas de runtime, rien à patcher.",[41,3648,3649,3652],{},[191,3650,3651],{},"Simplicité"," : le mutualisé sert des fichiers, c'est exactement son métier.",[15,3654,3656],{"id":3655},"le-workflow","Le workflow",[3658,3659,3661],"h3",{"id":3660},"_1-générer-le-site-en-local","1. Générer le site en local",[78,3663,3665],{"className":219,"code":3664,"language":221,"meta":83,"style":83},"npm run generate\n",[20,3666,3667],{"__ignoreMap":83},[87,3668,3669,3671,3674],{"class":89,"line":90},[87,3670,228],{"class":107},[87,3672,3673],{"class":162}," run",[87,3675,3676],{"class":162}," generate\n",[11,3678,3679,3680,3683],{},"Nuxt produit le site complet dans ",[20,3681,3682],{},".output\u002Fpublic\u002F"," : pages HTML, assets,\nsitemap, robots.txt.",[3658,3685,3687],{"id":3686},"_2-uploader-vers-o2switch","2. Uploader vers o2switch",[11,3689,3690,3691,3694,3695,3698],{},"Deux options : le gestionnaire de fichiers cPanel (zipper ",[20,3692,3693],{},".output\u002Fpublic",",\nuploader, extraire), ou un client FTP\u002FSFTP. La cible est le dossier\n",[20,3696,3697],{},"public_html\u002F"," de votre domaine.",[11,3700,3701,3702,3705,3706,3708,3709,3712,3713,2018],{},"Point important : uploadez ",[191,3703,3704],{},"le contenu"," de ",[20,3707,3682],{},", pas le\ndossier lui-même. ",[20,3710,3711],{},"index.html"," doit se retrouver directement à la racine de\n",[20,3714,3697],{},[3658,3716,3718],{"id":3717},"_3-configurer-le-htaccess","3. Configurer le .htaccess",[11,3720,3721,3722,3725],{},"Un fichier ",[20,3723,3724],{},".htaccess"," à la racine règle trois choses : la redirection\nHTTPS, la gestion des routes, et le cache des assets.",[78,3727,3731],{"className":3728,"code":3729,"language":3730,"meta":83,"style":83},"language-apache shiki shiki-themes github-dark","RewriteEngine On\n\n# Forcer HTTPS\nRewriteCond %{HTTPS} off\nRewriteRule ^ https:\u002F\u002F%{HTTP_HOST}%{REQUEST_URI} [L,R=301]\n\n# Servir les pages pré-rendues (routes propres sans .html)\nRewriteCond %{REQUEST_FILENAME} !-f\nRewriteCond %{REQUEST_FILENAME} !-d\nRewriteRule ^(.*)$ \u002F$1\u002Findex.html [L]\n\n# Cache long pour les assets fingerprintés\n\u003CFilesMatch \"\\.(js|css|woff2|png|jpg|webp|svg)$\">\n  Header set Cache-Control \"public, max-age=31536000, immutable\"\n\u003C\u002FFilesMatch>\n","apache",[20,3732,3733,3741,3745,3750,3762,3782,3786,3791,3801,3810,3823,3827,3832,3844,3861],{"__ignoreMap":83},[87,3734,3735,3738],{"class":89,"line":90},[87,3736,3737],{"class":100},"RewriteEngine",[87,3739,3740],{"class":107}," On\n",[87,3742,3743],{"class":89,"line":97},[87,3744,306],{"emptyLinePlaceholder":305},[87,3746,3747],{"class":89,"line":128},[87,3748,3749],{"class":93},"# Forcer HTTPS\n",[87,3751,3752,3755,3759],{"class":89,"line":153},[87,3753,3754],{"class":100},"RewriteCond",[87,3756,3758],{"class":3757},"sns5M"," %{HTTPS}",[87,3760,3761],{"class":162}," off\n",[87,3763,3764,3767,3770,3773,3776,3779],{"class":89,"line":177},[87,3765,3766],{"class":100},"RewriteRule",[87,3768,3769],{"class":3757}," ^",[87,3771,3772],{"class":162}," https:\u002F\u002F%{HTTP_HOST}%{REQUEST_URI}",[87,3774,3775],{"class":111}," [L,R=",[87,3777,3778],{"class":137},"301",[87,3780,3781],{"class":111},"]\n",[87,3783,3784],{"class":89,"line":309},[87,3785,306],{"emptyLinePlaceholder":305},[87,3787,3788],{"class":89,"line":320},[87,3789,3790],{"class":93},"# Servir les pages pré-rendues (routes propres sans .html)\n",[87,3792,3793,3795,3798],{"class":89,"line":330},[87,3794,3754],{"class":100},[87,3796,3797],{"class":3757}," %{REQUEST_FILENAME}",[87,3799,3800],{"class":162}," !-f\n",[87,3802,3803,3805,3807],{"class":89,"line":350},[87,3804,3754],{"class":100},[87,3806,3797],{"class":3757},[87,3808,3809],{"class":162}," !-d\n",[87,3811,3812,3814,3817,3820],{"class":89,"line":355},[87,3813,3766],{"class":100},[87,3815,3816],{"class":3757}," ^(.*)$",[87,3818,3819],{"class":162}," \u002F$1\u002Findex.html",[87,3821,3822],{"class":111}," [L]\n",[87,3824,3825],{"class":89,"line":360},[87,3826,306],{"emptyLinePlaceholder":305},[87,3828,3829],{"class":89,"line":371},[87,3830,3831],{"class":93},"# Cache long pour les assets fingerprintés\n",[87,3833,3834,3836,3839,3842],{"class":89,"line":394},[87,3835,792],{"class":111},[87,3837,3838],{"class":107},"FilesMatch",[87,3840,3841],{"class":162}," \"\\.(js|css|woff2|png|jpg|webp|svg)$\"",[87,3843,810],{"class":111},[87,3845,3846,3849,3852,3855,3858],{"class":89,"line":403},[87,3847,3848],{"class":100},"  Header",[87,3850,3851],{"class":107}," set",[87,3853,3854],{"class":111}," Cache-Control \"public, max-age=",[87,3856,3857],{"class":137},"31536000",[87,3859,3860],{"class":111},", immutable\"\n",[87,3862,3863,3865,3867],{"class":89,"line":411},[87,3864,931],{"class":111},[87,3866,3838],{"class":107},[87,3868,810],{"class":111},[3658,3870,3872],{"id":3871},"_4-mettre-à-jour","4. Mettre à jour",[11,3874,3875,3876,3879],{},"Chaque mise à jour suit le même cycle : modifier le contenu, ",[20,3877,3878],{},"npm run generate",", re-uploader. Pas de base de données à migrer, pas de plugin à\nmettre à jour.",[15,3881,3883],{"id":3882},"le-cas-du-formulaire-de-contact","Le cas du formulaire de contact",[11,3885,3886],{},"Un site statique n'a pas de serveur pour traiter un formulaire. Sur\no2switch, la solution la plus simple est un petit script PHP posé à côté du\nsite statique : le mutualisé exécute PHP nativement. Un honeypot (champ\ncaché que seuls les bots remplissent) suffit à filtrer l'essentiel du spam\nsans imposer de captcha aux visiteurs.",[11,3888,3889],{},"C'est l'un des rares cas où mélanger un site statique moderne et un script\nPHP de dix lignes est exactement la bonne solution : chaque outil fait ce\nqu'il sait faire.",[1046,3891,3892],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}",{"title":83,"searchDepth":97,"depth":97,"links":3894},[3895,3896,3902],{"id":3619,"depth":97,"text":3620},{"id":3655,"depth":97,"text":3656,"children":3897},[3898,3899,3900,3901],{"id":3660,"depth":128,"text":3661},{"id":3686,"depth":128,"text":3687},{"id":3717,"depth":128,"text":3718},{"id":3871,"depth":128,"text":3872},{"id":3882,"depth":97,"text":3883},"2026-07-01","Génération statique, upload cPanel, .htaccess : le workflow complet pour héberger un site Nuxt performant sur un mutualisé o2switch.",{},"\u002Fblog\u002Fdeployer-nuxt-statique-o2switch",{"title":3607,"description":3904},"blog\u002Fdeployer-nuxt-statique-o2switch",[1069,3910,3911],"Déploiement","o2switch","WDq_W7WrrizKwhUIjb8QPFMRc3325ZlzaPZMVgZMmVo",{"id":3914,"title":3915,"body":3916,"brouillon":1059,"date":4572,"description":4573,"extension":1062,"image":1063,"imageAlt":1063,"meta":4574,"navigation":305,"path":4575,"seo":4576,"stem":4577,"tags":4578,"__hash__":4579},"blog\u002Fblog\u002Ftester-une-application-flutter-strategie-complete.md","Tester une application Flutter : la stratégie complète, de mocktail aux widget tests",{"type":8,"value":3917,"toc":4566},[3918,3921,3925,3928,3935,4205,4225,4229,4236,4403,4406,4410,4413,4555,4558,4560,4563],[11,3919,3920],{},"Le test en Flutter souffre d'un malentendu : on pense « tester l'interface »\nalors que l'essentiel de la valeur est ailleurs. Voici la stratégie que\nj'applique (une pyramide dont chaque étage a un rôle précis) et le rôle\ncentral qu'y joue mocktail.",[15,3922,3924],{"id":3923},"létage-1-tests-unitaires-du-domaine","L'étage 1 : tests unitaires du domaine",[11,3926,3927],{},"Le socle : tester la logique métier pure (use cases, validations,\ntransformations) sans Flutter, sans réseau, sans base. C'est ici que la\nClean Architecture paie : un use case qui dépend d'une interface de\nrepository se teste avec un mock.",[11,3929,3930,3931,3934],{},"Pour les mocks, ",[191,3932,3933],{},"mocktail"," (le successeur spirituel de mockito, sans\ngénération de code) :",[78,3936,3938],{"className":1213,"code":3937,"language":1215,"meta":83,"style":83},"class MockAuthRepository extends Mock implements AuthRepository {}\n\nvoid main() {\n  late MockAuthRepository repository;\n  late VerifyOtpUseCase useCase;\n\n  setUp(() {\n    repository = MockAuthRepository();\n    useCase = VerifyOtpUseCase(repository);\n  });\n\n  test('retourne un échec quand le code OTP est expiré', () async {\n    when(() => repository.verifyOtp(any(), any()))\n        .thenAnswer((_) async => const Left(OtpExpiredFailure()));\n\n    final result = await useCase('+242066000000', '123456');\n\n    expect(result, const Left(OtpExpiredFailure()));\n    verify(() => repository.verifyOtp(any(), any())).called(1);\n  });\n}\n",[20,3939,3940,3961,3965,3975,3985,3995,3999,4007,4018,4030,4034,4038,4054,4082,4111,4115,4141,4145,4163,4197,4201],{"__ignoreMap":83},[87,3941,3942,3944,3947,3949,3952,3955,3958],{"class":89,"line":90},[87,3943,1554],{"class":100},[87,3945,3946],{"class":137}," MockAuthRepository",[87,3948,1560],{"class":100},[87,3950,3951],{"class":137}," Mock",[87,3953,3954],{"class":100}," implements",[87,3956,3957],{"class":137}," AuthRepository",[87,3959,3960],{"class":111}," {}\n",[87,3962,3963],{"class":89,"line":97},[87,3964,306],{"emptyLinePlaceholder":305},[87,3966,3967,3970,3973],{"class":89,"line":128},[87,3968,3969],{"class":100},"void",[87,3971,3972],{"class":107}," main",[87,3974,1823],{"class":111},[87,3976,3977,3980,3982],{"class":89,"line":153},[87,3978,3979],{"class":100},"  late",[87,3981,3946],{"class":137},[87,3983,3984],{"class":111}," repository;\n",[87,3986,3987,3989,3992],{"class":89,"line":177},[87,3988,3979],{"class":100},[87,3990,3991],{"class":137}," VerifyOtpUseCase",[87,3993,3994],{"class":111}," useCase;\n",[87,3996,3997],{"class":89,"line":309},[87,3998,306],{"emptyLinePlaceholder":305},[87,4000,4001,4004],{"class":89,"line":320},[87,4002,4003],{"class":107},"  setUp",[87,4005,4006],{"class":111},"(() {\n",[87,4008,4009,4012,4014,4016],{"class":89,"line":330},[87,4010,4011],{"class":111},"    repository ",[87,4013,144],{"class":100},[87,4015,3946],{"class":137},[87,4017,3263],{"class":111},[87,4019,4020,4023,4025,4027],{"class":89,"line":350},[87,4021,4022],{"class":111},"    useCase ",[87,4024,144],{"class":100},[87,4026,3991],{"class":137},[87,4028,4029],{"class":111},"(repository);\n",[87,4031,4032],{"class":89,"line":355},[87,4033,3299],{"class":111},[87,4035,4036],{"class":89,"line":360},[87,4037,306],{"emptyLinePlaceholder":305},[87,4039,4040,4043,4045,4048,4050,4052],{"class":89,"line":371},[87,4041,4042],{"class":107},"  test",[87,4044,341],{"class":111},[87,4046,4047],{"class":162},"'retourne un échec quand le code OTP est expiré'",[87,4049,1426],{"class":111},[87,4051,570],{"class":100},[87,4053,125],{"class":111},[87,4055,4056,4059,4061,4063,4066,4069,4071,4074,4077,4079],{"class":89,"line":394},[87,4057,4058],{"class":100},"    when",[87,4060,1187],{"class":111},[87,4062,122],{"class":100},[87,4064,4065],{"class":111}," repository.",[87,4067,4068],{"class":107},"verifyOtp",[87,4070,341],{"class":111},[87,4072,4073],{"class":107},"any",[87,4075,4076],{"class":111},"(), ",[87,4078,4073],{"class":107},[87,4080,4081],{"class":111},"()))\n",[87,4083,4084,4087,4090,4093,4095,4098,4100,4103,4105,4108],{"class":89,"line":403},[87,4085,4086],{"class":111},"        .",[87,4088,4089],{"class":107},"thenAnswer",[87,4091,4092],{"class":111},"((_) ",[87,4094,570],{"class":100},[87,4096,4097],{"class":100}," =>",[87,4099,522],{"class":100},[87,4101,4102],{"class":137}," Left",[87,4104,341],{"class":111},[87,4106,4107],{"class":137},"OtpExpiredFailure",[87,4109,4110],{"class":111},"()));\n",[87,4112,4113],{"class":89,"line":411},[87,4114,306],{"emptyLinePlaceholder":305},[87,4116,4117,4120,4123,4125,4127,4130,4132,4134,4136,4139],{"class":89,"line":430},[87,4118,4119],{"class":100},"    final",[87,4121,4122],{"class":111}," result ",[87,4124,144],{"class":100},[87,4126,584],{"class":100},[87,4128,4129],{"class":107}," useCase",[87,4131,341],{"class":111},[87,4133,3520],{"class":162},[87,4135,72],{"class":111},[87,4137,4138],{"class":162},"'123456'",[87,4140,1248],{"class":111},[87,4142,4143],{"class":89,"line":448},[87,4144,306],{"emptyLinePlaceholder":305},[87,4146,4147,4150,4153,4155,4157,4159,4161],{"class":89,"line":3302},[87,4148,4149],{"class":107},"    expect",[87,4151,4152],{"class":111},"(result, ",[87,4154,815],{"class":100},[87,4156,4102],{"class":137},[87,4158,341],{"class":111},[87,4160,4107],{"class":137},[87,4162,4110],{"class":111},[87,4164,4165,4168,4170,4172,4174,4176,4178,4180,4182,4184,4187,4190,4192,4195],{"class":89,"line":3307},[87,4166,4167],{"class":107},"    verify",[87,4169,1187],{"class":111},[87,4171,122],{"class":100},[87,4173,4065],{"class":111},[87,4175,4068],{"class":107},[87,4177,341],{"class":111},[87,4179,4073],{"class":107},[87,4181,4076],{"class":111},[87,4183,4073],{"class":107},[87,4185,4186],{"class":111},"())).",[87,4188,4189],{"class":107},"called",[87,4191,341],{"class":111},[87,4193,4194],{"class":137},"1",[87,4196,1248],{"class":111},[87,4198,4199],{"class":89,"line":3339},[87,4200,3299],{"class":111},[87,4202,4203],{"class":89,"line":3350},[87,4204,300],{"class":111},[11,4206,4207,4208,4210,4211,2876,4214,4217,4218,651,4221,4224],{},"Pas de build_runner, pas de fichiers générés : ",[20,4209,3933],{}," crée le mock par\nhéritage, et ",[20,4212,4213],{},"when",[20,4215,4216],{},"verify"," pilotent le comportement. Seule subtilité :\nenregistrer les fallback values pour les types personnalisés passés à\n",[20,4219,4220],{},"any()",[20,4222,4223],{},"registerFallbackValue","). C'est le piège classique du débutant.",[15,4226,4228],{"id":4227},"létage-2-tests-de-blocs","L'étage 2 : tests de blocs",[11,4230,4231,4232,4235],{},"Les blocs\u002Fcubits sont la logique de présentation ; le package ",[20,4233,4234],{},"bloc_test","\nles teste comme des scénarios :",[78,4237,4239],{"className":1213,"code":4238,"language":1215,"meta":83,"style":83},"blocTest\u003COtpCubit, OtpState>(\n  'émet [loading, failure] quand le code est invalide',\n  build: () {\n    when(() => useCase(any(), any()))\n        .thenAnswer((_) async => const Left(InvalidOtpFailure()));\n    return OtpCubit(useCase);\n  },\n  act: (cubit) => cubit.verify('+242066000000', '000000'),\n  expect: () => [OtpState.loading(), OtpState.failure(InvalidOtpFailure())],\n);\n",[20,4240,4241,4259,4266,4276,4296,4319,4330,4335,4362,4399],{"__ignoreMap":83},[87,4242,4243,4246,4248,4251,4253,4256],{"class":89,"line":90},[87,4244,4245],{"class":107},"blocTest",[87,4247,792],{"class":111},[87,4249,4250],{"class":137},"OtpCubit",[87,4252,72],{"class":111},[87,4254,4255],{"class":137},"OtpState",[87,4257,4258],{"class":111},">(\n",[87,4260,4261,4264],{"class":89,"line":97},[87,4262,4263],{"class":162},"  'émet [loading, failure] quand le code est invalide'",[87,4265,1124],{"class":111},[87,4267,4268,4271,4273],{"class":89,"line":128},[87,4269,4270],{"class":111},"  build",[87,4272,869],{"class":100},[87,4274,4275],{"class":111}," () {\n",[87,4277,4278,4280,4282,4284,4286,4288,4290,4292,4294],{"class":89,"line":153},[87,4279,4058],{"class":100},[87,4281,1187],{"class":111},[87,4283,122],{"class":100},[87,4285,4129],{"class":107},[87,4287,341],{"class":111},[87,4289,4073],{"class":107},[87,4291,4076],{"class":111},[87,4293,4073],{"class":107},[87,4295,4081],{"class":111},[87,4297,4298,4300,4302,4304,4306,4308,4310,4312,4314,4317],{"class":89,"line":177},[87,4299,4086],{"class":111},[87,4301,4089],{"class":107},[87,4303,4092],{"class":111},[87,4305,570],{"class":100},[87,4307,4097],{"class":100},[87,4309,522],{"class":100},[87,4311,4102],{"class":137},[87,4313,341],{"class":111},[87,4315,4316],{"class":137},"InvalidOtpFailure",[87,4318,4110],{"class":111},[87,4320,4321,4324,4327],{"class":89,"line":309},[87,4322,4323],{"class":100},"    return",[87,4325,4326],{"class":137}," OtpCubit",[87,4328,4329],{"class":111},"(useCase);\n",[87,4331,4332],{"class":89,"line":320},[87,4333,4334],{"class":111},"  },\n",[87,4336,4337,4340,4342,4345,4347,4350,4352,4354,4356,4358,4360],{"class":89,"line":330},[87,4338,4339],{"class":111},"  act",[87,4341,869],{"class":100},[87,4343,4344],{"class":111}," (cubit) ",[87,4346,122],{"class":100},[87,4348,4349],{"class":111}," cubit.",[87,4351,4216],{"class":107},[87,4353,341],{"class":111},[87,4355,3520],{"class":162},[87,4357,72],{"class":111},[87,4359,3526],{"class":162},[87,4361,3386],{"class":111},[87,4363,4364,4367,4369,4371,4373,4376,4378,4380,4383,4385,4387,4389,4392,4394,4396],{"class":89,"line":350},[87,4365,4366],{"class":111},"  expect",[87,4368,869],{"class":100},[87,4370,573],{"class":111},[87,4372,122],{"class":100},[87,4374,4375],{"class":111}," [",[87,4377,4255],{"class":137},[87,4379,2018],{"class":111},[87,4381,4382],{"class":107},"loading",[87,4384,4076],{"class":111},[87,4386,4255],{"class":137},[87,4388,2018],{"class":111},[87,4390,4391],{"class":107},"failure",[87,4393,341],{"class":111},[87,4395,4316],{"class":137},[87,4397,4398],{"class":111},"())],\n",[87,4400,4401],{"class":89,"line":355},[87,4402,1248],{"class":111},[11,4404,4405],{},"Ces tests sont rapides, lisibles, et documentent les transitions d'état\nmieux que n'importe quel commentaire. C'est l'étage au meilleur rapport\nprotection\u002Feffort : si vous ne testez qu'une chose, testez ici.",[15,4407,4409],{"id":4408},"létage-3-widget-tests-avec-parcimonie","L'étage 3 : widget tests, avec parcimonie",[11,4411,4412],{},"Les widget tests vérifient qu'un écran réagit correctement à un état donné :\non injecte un bloc mocké, on pompe le widget, on vérifie le rendu.",[78,4414,4416],{"className":1213,"code":4415,"language":1215,"meta":83,"style":83},"testWidgets('affiche le message d\\'erreur en cas d\\'échec', (tester) async {\n  whenListen(mockCubit, Stream.value(OtpState.failure(InvalidOtpFailure())),\n      initialState: OtpState.initial());\n  await tester.pumpWidget(makeTestable(OtpScreen(cubit: mockCubit)));\n  await tester.pump();\n  expect(find.text('Code invalide'), findsOneWidget);\n});\n",[20,4417,4418,4445,4476,4494,4522,4533,4551],{"__ignoreMap":83},[87,4419,4420,4423,4425,4428,4430,4433,4435,4438,4441,4443],{"class":89,"line":90},[87,4421,4422],{"class":107},"testWidgets",[87,4424,341],{"class":111},[87,4426,4427],{"class":162},"'affiche le message d",[87,4429,3318],{"class":137},[87,4431,4432],{"class":162},"erreur en cas d",[87,4434,3318],{"class":137},[87,4436,4437],{"class":162},"échec'",[87,4439,4440],{"class":111},", (tester) ",[87,4442,570],{"class":100},[87,4444,125],{"class":111},[87,4446,4447,4450,4453,4456,4458,4461,4463,4465,4467,4469,4471,4473],{"class":89,"line":97},[87,4448,4449],{"class":107},"  whenListen",[87,4451,4452],{"class":111},"(mockCubit, ",[87,4454,4455],{"class":137},"Stream",[87,4457,2018],{"class":111},[87,4459,4460],{"class":107},"value",[87,4462,341],{"class":111},[87,4464,4255],{"class":137},[87,4466,2018],{"class":111},[87,4468,4391],{"class":107},[87,4470,341],{"class":111},[87,4472,4316],{"class":137},[87,4474,4475],{"class":111},"())),\n",[87,4477,4478,4481,4483,4486,4488,4491],{"class":89,"line":128},[87,4479,4480],{"class":111},"      initialState",[87,4482,869],{"class":100},[87,4484,4485],{"class":137}," OtpState",[87,4487,2018],{"class":111},[87,4489,4490],{"class":107},"initial",[87,4492,4493],{"class":111},"());\n",[87,4495,4496,4498,4501,4504,4506,4509,4511,4514,4517,4519],{"class":89,"line":153},[87,4497,897],{"class":100},[87,4499,4500],{"class":111}," tester.",[87,4502,4503],{"class":107},"pumpWidget",[87,4505,341],{"class":111},[87,4507,4508],{"class":107},"makeTestable",[87,4510,341],{"class":111},[87,4512,4513],{"class":137},"OtpScreen",[87,4515,4516],{"class":111},"(cubit",[87,4518,869],{"class":100},[87,4520,4521],{"class":111}," mockCubit)));\n",[87,4523,4524,4526,4528,4531],{"class":89,"line":177},[87,4525,897],{"class":100},[87,4527,4500],{"class":111},[87,4529,4530],{"class":107},"pump",[87,4532,3263],{"class":111},[87,4534,4535,4537,4540,4543,4545,4548],{"class":89,"line":309},[87,4536,4366],{"class":107},[87,4538,4539],{"class":111},"(find.",[87,4541,4542],{"class":107},"text",[87,4544,341],{"class":111},[87,4546,4547],{"class":162},"'Code invalide'",[87,4549,4550],{"class":111},"), findsOneWidget);\n",[87,4552,4553],{"class":89,"line":320},[87,4554,3409],{"class":111},[11,4556,4557],{},"Mon conseil : réservez-les aux écrans critiques (auth, paiement, saisie de\ndonnées importantes) et aux composants réutilisés partout. Tester chaque\nécran pixel par pixel coûte cher en maintenance pour une protection\nmarginale. C'est la partie de la pyramide qu'on regrette d'avoir trop\nconstruite.",[15,4559,1038],{"id":1037},[11,4561,4562],{},"La bonne pyramide Flutter : beaucoup de tests de domaine, une couverture\nsystématique des blocs, quelques widget tests ciblés. Et une règle\nd'hygiène : chaque bug corrigé gagne son test de non-régression, c'est la\nseule couverture qui croît exactement là où votre application casse\nvraiment.",[1046,4564,4565],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":83,"searchDepth":97,"depth":97,"links":4567},[4568,4569,4570,4571],{"id":3923,"depth":97,"text":3924},{"id":4227,"depth":97,"text":4228},{"id":4408,"depth":97,"text":4409},{"id":1037,"depth":97,"text":1038},"2026-06-23","Tests unitaires, tests de blocs, widget tests : comment structurer une pyramide de tests Flutter qui protège vraiment, sans ralentir le développement.",{},"\u002Fblog\u002Ftester-une-application-flutter-strategie-complete",{"title":3915,"description":4573},"blog\u002Ftester-une-application-flutter-strategie-complete",[1107,3602],"XlLEaWC-cR7xQ2oKP2lNwKjnGHIgOv1t9o_c97sbupY",{"id":4581,"title":4582,"body":4583,"brouillon":1059,"date":4893,"description":4894,"extension":1062,"image":1063,"imageAlt":1063,"meta":4895,"navigation":305,"path":4896,"seo":4897,"stem":4898,"tags":4899,"__hash__":4901},"blog\u002Fblog\u002Fgo-router-state-extra-refreshlistenable.md","go_router : quand state.extra disparaît avec refreshListenable",{"type":8,"value":4584,"toc":4888},[4585,4588,4592,4599,4624,4634,4638,4645,4694,4713,4719,4723,4726,4858,4861,4877,4885],[11,4586,4587],{},"Sur un flux d'authentification par OTP, j'ai rencontré un bug déroutant :\naprès validation du code, l'utilisateur était renvoyé vers l'écran de\nconnexion au lieu de continuer vers son profil. Aucune erreur, aucun log\nsuspect : juste une redirection fantôme.",[15,4589,4591],{"id":4590},"le-symptôme","Le symptôme",[11,4593,4594,4595,4598],{},"Le numéro de téléphone était transmis à l'écran de vérification OTP via le\nparamètre ",[20,4596,4597],{},"extra"," de go_router :",[78,4600,4602],{"className":1213,"code":4601,"language":1215,"meta":83,"style":83},"context.push('\u002Fverify-otp', extra: phoneNumber);\n",[20,4603,4604],{"__ignoreMap":83},[87,4605,4606,4609,4611,4613,4616,4619,4621],{"class":89,"line":90},[87,4607,4608],{"class":111},"context.",[87,4610,1527],{"class":107},[87,4612,341],{"class":111},[87,4614,4615],{"class":162},"'\u002Fverify-otp'",[87,4617,4618],{"class":111},", extra",[87,4620,869],{"class":100},[87,4622,4623],{"class":111}," phoneNumber);\n",[11,4625,4626,4627,4630,4631,4633],{},"Dans la majorité des cas, tout fonctionnait. Mais dès que l'état\nd'authentification changeait pendant que l'écran OTP était affiché, le\n",[20,4628,4629],{},"state.extra"," devenait ",[20,4632,1770],{}," et la garde de route renvoyait l'utilisateur\nau point de départ.",[15,4635,4637],{"id":4636},"la-cause","La cause",[11,4639,4640,4641,4644],{},"Le routeur était configuré avec un ",[20,4642,4643],{},"refreshListenable"," branché sur le bloc\nd'authentification. C'est le pattern classique pour déclencher les\nredirections quand l'utilisateur se connecte ou se déconnecte :",[78,4646,4648],{"className":1213,"code":4647,"language":1215,"meta":83,"style":83},"GoRouter(\n  refreshListenable: GoRouterRefreshStream(authBloc.stream),\n  redirect: (context, state) { \u002F* ... *\u002F },\n  \u002F\u002F ...\n)\n",[20,4649,4650,4657,4670,4685,4690],{"__ignoreMap":83},[87,4651,4652,4655],{"class":89,"line":90},[87,4653,4654],{"class":137},"GoRouter",[87,4656,3364],{"class":111},[87,4658,4659,4662,4664,4667],{"class":89,"line":97},[87,4660,4661],{"class":111},"  refreshListenable",[87,4663,869],{"class":100},[87,4665,4666],{"class":137}," GoRouterRefreshStream",[87,4668,4669],{"class":111},"(authBloc.stream),\n",[87,4671,4672,4675,4677,4680,4683],{"class":89,"line":128},[87,4673,4674],{"class":111},"  redirect",[87,4676,869],{"class":100},[87,4678,4679],{"class":111}," (context, state) { ",[87,4681,4682],{"class":93},"\u002F* ... *\u002F",[87,4684,604],{"class":111},[87,4686,4687],{"class":89,"line":153},[87,4688,4689],{"class":93},"  \u002F\u002F ...\n",[87,4691,4692],{"class":89,"line":177},[87,4693,347],{"class":111},[11,4695,4696,4697,4699,4700,4706,4707,4709,4710,4712],{},"Or, quand le ",[20,4698,4643],{}," notifie, go_router ",[191,4701,4702,4703],{},"réévalue la route\ncourante en reconstruisant un nouveau ",[20,4704,4705],{},"state",", et ",[20,4708,4597],{}," n'est pas\nconservé dans cette reconstruction. ",[20,4711,4597],{}," est un objet Dart passé en\nmémoire au moment de la navigation ; il ne fait pas partie de l'URL, donc il\nne survit pas à une réévaluation.",[11,4714,4715,4716,4718],{},"C'est un piège documenté mais facile à manquer : ",[20,4717,4597],{}," est fiable pour une\nnavigation ponctuelle, pas pour un état qui doit survivre aux rebuilds du\nrouteur.",[15,4720,4722],{"id":4721},"la-solution-faire-porter-létat-par-lurl","La solution : faire porter l'état par l'URL",[11,4724,4725],{},"La correction consiste à passer les données indispensables dans les\nparamètres de requête, qui font partie de l'URL et survivent donc à toutes\nles réévaluations :",[78,4727,4729],{"className":1213,"code":4728,"language":1215,"meta":83,"style":83},"\u002F\u002F Un helper centralisé plutôt que des URLs construites à la main\nabstract class Routes {\n  static String verifyOtpWith(String phone) =>\n      '\u002Fverify-otp?phone=${Uri.encodeComponent(phone)}';\n}\n\n\u002F\u002F À l'appel\ncontext.push(Routes.verifyOtpWith(phoneNumber));\n\n\u002F\u002F À la lecture\nfinal phone = state.uri.queryParameters['phone'];\n",[20,4730,4731,4736,4749,4770,4799,4803,4807,4812,4831,4835,4840],{"__ignoreMap":83},[87,4732,4733],{"class":89,"line":90},[87,4734,4735],{"class":93},"\u002F\u002F Un helper centralisé plutôt que des URLs construites à la main\n",[87,4737,4738,4741,4744,4747],{"class":89,"line":97},[87,4739,4740],{"class":100},"abstract",[87,4742,4743],{"class":100}," class",[87,4745,4746],{"class":137}," Routes",[87,4748,125],{"class":111},[87,4750,4751,4754,4757,4760,4762,4765,4768],{"class":89,"line":128},[87,4752,4753],{"class":100},"  static",[87,4755,4756],{"class":137}," String",[87,4758,4759],{"class":107}," verifyOtpWith",[87,4761,341],{"class":111},[87,4763,4764],{"class":137},"String",[87,4766,4767],{"class":111}," phone) ",[87,4769,1465],{"class":100},[87,4771,4772,4775,4778,4781,4783,4786,4788,4791,4794,4797],{"class":89,"line":153},[87,4773,4774],{"class":162},"      '\u002Fverify-otp?phone=",[87,4776,4777],{"class":162},"${",[87,4779,4780],{"class":137},"Uri",[87,4782,2018],{"class":162},[87,4784,4785],{"class":107},"encodeComponent",[87,4787,341],{"class":162},[87,4789,4790],{"class":137},"phone",[87,4792,4793],{"class":162},")}",[87,4795,4796],{"class":162},"'",[87,4798,3162],{"class":111},[87,4800,4801],{"class":89,"line":177},[87,4802,300],{"class":111},[87,4804,4805],{"class":89,"line":309},[87,4806,306],{"emptyLinePlaceholder":305},[87,4808,4809],{"class":89,"line":320},[87,4810,4811],{"class":93},"\u002F\u002F À l'appel\n",[87,4813,4814,4816,4818,4820,4823,4825,4828],{"class":89,"line":330},[87,4815,4608],{"class":111},[87,4817,1527],{"class":107},[87,4819,341],{"class":111},[87,4821,4822],{"class":137},"Routes",[87,4824,2018],{"class":111},[87,4826,4827],{"class":107},"verifyOtpWith",[87,4829,4830],{"class":111},"(phoneNumber));\n",[87,4832,4833],{"class":89,"line":350},[87,4834,306],{"emptyLinePlaceholder":305},[87,4836,4837],{"class":89,"line":355},[87,4838,4839],{"class":93},"\u002F\u002F À la lecture\n",[87,4841,4842,4844,4847,4849,4852,4855],{"class":89,"line":360},[87,4843,1227],{"class":100},[87,4845,4846],{"class":111}," phone ",[87,4848,144],{"class":100},[87,4850,4851],{"class":111}," state.uri.queryParameters[",[87,4853,4854],{"class":162},"'phone'",[87,4856,4857],{"class":111},"];\n",[11,4859,4860],{},"Deux règles que j'en ai tirées :",[38,4862,4863,4871],{},[41,4864,4865,4870],{},[191,4866,4867,4869],{},[20,4868,4597],{}," uniquement pour du confort éphémère"," (un objet déjà chargé\nqu'on évite de recharger), jamais pour une donnée dont dépend la logique\nde navigation.",[41,4872,4873,4876],{},[191,4874,4875],{},"Centraliser la construction des routes"," dans une classe dédiée. Le jour\noù un paramètre change, il n'y a qu'un seul endroit à modifier, et\nl'encodage URL est garanti partout.",[11,4878,4879,4880,4882,4883,2018],{},"Ce genre de bug coûte des heures parce qu'il est intermittent : il ne se\nmanifeste que si le ",[20,4881,4643],{}," notifie au mauvais moment. Si votre\nflux OTP ou deep-link « oublie » ses données par moments, vérifiez ce que\nvous transportez dans ",[20,4884,4597],{},[1046,4886,4887],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":83,"searchDepth":97,"depth":97,"links":4889},[4890,4891,4892],{"id":4590,"depth":97,"text":4591},{"id":4636,"depth":97,"text":4637},{"id":4721,"depth":97,"text":4722},"2026-06-15","Un bug subtil en Flutter : les données passées via extra sont perdues quand le routeur se reconstruit. Diagnostic et solution durable.",{},"\u002Fblog\u002Fgo-router-state-extra-refreshlistenable",{"title":4582,"description":4894},"blog\u002Fgo-router-state-extra-refreshlistenable",[1107,4900],"go_router","Jd7B4YC9yg_fyJWx3u2ubjFgAwz9BnUm2yn0S_4XE5I",{"id":4903,"title":4904,"body":4905,"brouillon":1059,"date":4982,"description":4983,"extension":1062,"image":1063,"imageAlt":1063,"meta":4984,"navigation":305,"path":4985,"seo":4986,"stem":4987,"tags":4988,"__hash__":4989},"blog\u002Fblog\u002Fflutter-bloc-vs-riverpod-2026.md","flutter_bloc ou Riverpod en 2026 : comment choisir vraiment",{"type":8,"value":4906,"toc":4976},[4907,4914,4918,4924,4930,4934,4940,4949,4955,4961,4965,4968,4971,4973],[11,4908,4909,4910,4913],{},"C'est la question rituelle de tout nouveau projet Flutter, et les débats en\nligne la traitent comme une guerre de religion. En 2026, la vraie réponse\nest plus ennuyeuse : ",[191,4911,4912],{},"les deux sont matures, maintenus et capables de\nporter n'importe quelle application",". Le choix ne se fait pas sur la\npuissance, mais sur l'adéquation à votre équipe et à votre projet. Voici\nles critères qui comptent.",[15,4915,4917],{"id":4916},"ce-qui-les-distingue-vraiment","Ce qui les distingue vraiment",[11,4919,4920,4923],{},[191,4921,4922],{},"flutter_bloc"," impose une discipline événementielle : l'interface émet\ndes événements (ou appelle des méthodes de Cubit), la logique produit des\nétats, l'interface reconstruit. Tout est explicite, séquencé, traçable.\nC'est verbeux, et c'est le but : le flux de données a une seule forme\npossible, qu'on retrouve identique dans chaque feature.",[11,4925,4926,4929],{},[191,4927,4928],{},"Riverpod"," est un graphe de dépendances réactif : des providers qui\nexposent des valeurs, se recalculent quand leurs dépendances changent, et\nque l'interface observe. C'est plus concis, plus flexible, et la\ncombinaison de providers (un provider qui filtre les résultats d'un autre)\nest d'une élégance que bloc n'égale pas.",[15,4931,4933],{"id":4932},"les-critères-de-décision","Les critères de décision",[11,4935,4936,4939],{},[191,4937,4938],{},"La taille et la rotation de l'équipe."," C'est, à mon avis, le critère\nn°1. La rigidité de bloc est une force quand plusieurs personnes (ou\nplusieurs générations de contributeurs) touchent au code : impossible de\nfaire « créatif », le pattern canalise tout le monde. La liberté de Riverpod\ndemande plus de conventions d'équipe pour ne pas dériver en spaghetti de\nproviders.",[11,4941,4942,4945,4946,4948],{},[191,4943,4944],{},"Le rapport à la testabilité."," Les deux se testent très bien, mais\ndifféremment : bloc excelle au test de séquences (tel événement produit\ntels états, dans cet ordre ; ",[20,4947,4245],{}," rend cela trivial), Riverpod au\ntest d'assemblage (remplacer un provider par un mock via les overrides).\nSi votre logique métier est faite de processus séquencés (authentification\nmulti-étapes, synchronisation, machines à états), bloc raconte mieux cette\nhistoire.",[11,4950,4951,4954],{},[191,4952,4953],{},"La nature de l'état."," Beaucoup d'états dérivés et combinés (filtres,\nrecherches, agrégations réactives) : avantage Riverpod. Des flux métier\navec transitions explicites et effets contrôlés : avantage bloc.",[11,4956,4957,4960],{},[191,4958,4959],{},"L'existant."," Sur un projet en cours, la meilleure solution est presque\ntoujours celle déjà en place. Une migration de state management ne\ns'amortit à peu près jamais.",[15,4962,4964],{"id":4963},"mon-choix-et-pourquoi-il-ne-devrait-pas-être-le-vôtre-par-défaut","Mon choix, et pourquoi il ne devrait pas être le vôtre par défaut",[11,4966,4967],{},"Sur mes projets au long cours, j'ai choisi flutter_bloc (Cubit pour le\nsimple, BLoC pour les flux complexes), adossé à une Clean Architecture.\nRaison principale : un projet mené en solo avec l'assistance d'outils d'IA\nbénéficie énormément d'un pattern ultra-régulier : chaque feature a la même\nforme, ce qui rend le code prévisible à générer, à relire et à tester. La\nverbosité, dans ce contexte, est un atout de contrôle.",[11,4969,4970],{},"Mais si votre équipe est petite, expérimentée, et que votre application vit\nd'états dérivés, Riverpod est probablement le choix plus confortable. Les\ndeux réponses sont correctes ; c'est la justification qui doit être la\nvôtre.",[15,4972,1038],{"id":1037},[11,4974,4975],{},"Choisissez sur vos contraintes, écrivez la décision quelque part (un court\nADR suffit), et n'y revenez plus. L'énergie dépensée à re-débattre du state\nmanagement est de l'énergie volée aux fonctionnalités.",{"title":83,"searchDepth":97,"depth":97,"links":4977},[4978,4979,4980,4981],{"id":4916,"depth":97,"text":4917},{"id":4932,"depth":97,"text":4933},{"id":4963,"depth":97,"text":4964},{"id":1037,"depth":97,"text":1038},"2026-06-09","Au-delà des débats de chapelle : les critères qui doivent décider entre flutter_bloc et Riverpod selon votre équipe et votre projet.",{},"\u002Fblog\u002Fflutter-bloc-vs-riverpod-2026",{"title":4904,"description":4983},"blog\u002Fflutter-bloc-vs-riverpod-2026",[1107,3105],"AWN-trLw-0jjk6g-39GCGtH6-daRgge67cjbZX7ys8A",{"id":4991,"title":4992,"body":4993,"brouillon":1059,"date":5119,"description":5120,"extension":1062,"image":1063,"imageAlt":1063,"meta":5121,"navigation":305,"path":5122,"seo":5123,"stem":5124,"tags":5125,"__hash__":5126},"blog\u002Fblog\u002Fclean-architecture-flutter-pourquoi-comment.md","Clean Architecture en Flutter : pourquoi je l'impose sur mes projets au long cours",{"type":8,"value":4994,"toc":5113},[4995,5009,5013,5024,5031,5054,5058,5064,5074,5080,5084,5087,5108,5110],[11,4996,4997,4998,5001,5002,1872,5005,5008],{},"Sur un petit projet Flutter, tout mettre dans ",[20,4999,5000],{},"lib\u002F"," avec quelques dossiers\n",[20,5003,5004],{},"screens\u002F",[20,5006,5007],{},"widgets\u002F"," fonctionne très bien. Puis le projet grandit, un\ndeuxième contributeur arrive (ou vous-même, six mois plus tard), et chaque\nmodification devient une expédition. La Clean Architecture est la réponse\nque j'ai adoptée pour tout projet destiné à vivre plus d'un an ; elle se\npaie dès les premières semaines.",[15,5010,5012],{"id":5011},"la-structure","La structure",[11,5014,5015,5016,5019,5020,5023],{},"Le principe : séparer ",[191,5017,5018],{},"ce que fait l'application"," (le domaine) de\n",[191,5021,5022],{},"comment elle le fait"," (les détails techniques). Concrètement :",[78,5025,5029],{"className":5026,"code":5028,"language":4542},[5027],"language-text","lib\u002F\n├── core\u002F                  # transversal : erreurs, réseau, DI, thème\n└── features\u002F\n    └── auth\u002F\n        ├── data\u002F          # sources (API, base locale), repositories impl\n        ├── domain\u002F        # entités, interfaces de repositories, use cases\n        └── presentation\u002F  # blocs\u002Fcubits, écrans, widgets\n",[20,5030,5028],{"__ignoreMap":83},[11,5032,5033,5034,5037,5038,5041,5042,5045,5046,5048,5049,5045,5051,5053],{},"La règle de dépendance est la seule chose à retenir : ",[191,5035,5036],{},"les flèches\npointent vers le domaine",". La couche ",[20,5039,5040],{},"presentation"," ne connaît que\n",[20,5043,5044],{},"domain"," ; ",[20,5047,820],{}," implémente les interfaces définies par ",[20,5050,5044],{},[20,5052,5044],{},"\nne connaît personne. Votre logique métier ne sait pas si les données\nviennent d'une API REST, d'une base SQLite ou d'un mock de test.",[15,5055,5057],{"id":5056},"ce-que-ça-rapporte-concrètement","Ce que ça rapporte concrètement",[11,5059,5060,5063],{},[191,5061,5062],{},"La testabilité d'abord."," Un use case qui dépend d'une interface de\nrepository se teste avec un mock en trois lignes, sans réseau, sans base,\nsans widget. C'est ce qui rend possible une vraie couverture de tests sur\nla logique métier, celle qui coûte cher quand elle casse.",[11,5065,5066,5069,5070,5073],{},[191,5067,5068],{},"Le remplacement des détails ensuite."," Changer de solution de stockage\nlocal, remplacer un endpoint, passer d'un backend à un autre : si la\nfrontière data\u002Fdomain est propre, c'est un chantier localisé dans ",[20,5071,5072],{},"data\u002F",",\npas une réécriture.",[11,5075,5076,5079],{},[191,5077,5078],{},"L'onboarding enfin."," Sur une feature inconnue, on sait toujours où\nregarder : le use case dit ce que fait la feature, le bloc dit comment\nl'écran réagit, le repository dit d'où viennent les données. La structure\nest la documentation.",[15,5081,5083],{"id":5082},"ce-que-ça-coûte-soyons-honnêtes","Ce que ça coûte, soyons honnêtes",[11,5085,5086],{},"Plus de fichiers, plus d'indirection, et une cérémonie qui peut sembler\nabsurde sur un CRUD simple (une entité, un modèle, un mapper, une\ninterface, une implémentation... pour un écran de liste). Deux\ngarde-fous :",[38,5088,5089,5095],{},[41,5090,5091,5094],{},[191,5092,5093],{},"Ne créez un use case que quand il y a de la logique."," Pour un simple\npassage de plat, le bloc peut appeler le repository directement : la\npureté dogmatique est l'ennemie de l'adoption.",[41,5096,5097,5100,5101,72,5104,5107],{},[191,5098,5099],{},"La structure par feature est plus importante que le nombre de\ncouches."," Si vous ne retenez qu'une chose : isolez chaque feature dans\nson dossier avec ses trois couches, plutôt que des couches globales\n(",[20,5102,5103],{},"models\u002F",[20,5105,5106],{},"services\u002F",") qui mélangent toutes les features.",[15,5109,1038],{"id":1037},[11,5111,5112],{},"La Clean Architecture n'est pas un badge de sérieux, c'est un pari sur la\ndurée : vous acceptez d'écrire un peu plus aujourd'hui pour que le projet\nreste modifiable dans deux ans. Sur un prototype jetable, c'est du\nsur-coût. Sur un produit au long cours (celui qu'on maintient seul le\nsoir, celui qui doit survivre à ses propres pivots), c'est ce qui fait la\ndifférence entre itérer et réécrire.",{"title":83,"searchDepth":97,"depth":97,"links":5114},[5115,5116,5117,5118],{"id":5011,"depth":97,"text":5012},{"id":5056,"depth":97,"text":5057},{"id":5082,"depth":97,"text":5083},{"id":1037,"depth":97,"text":1038},"2026-05-26","Structure core\u002Ffeatures, séparation data\u002Fdomain\u002Fpresentation : ce que la Clean Architecture coûte au démarrage et ce qu'elle rapporte sur la durée.",{},"\u002Fblog\u002Fclean-architecture-flutter-pourquoi-comment",{"title":4992,"description":5120},"blog\u002Fclean-architecture-flutter-pourquoi-comment",[1107,3105],"OXswTRsWKh92HKBIUtQ3ue33rCRjALj3i2egnettd2s",{"id":5128,"title":5129,"body":5130,"brouillon":1059,"date":5432,"description":5433,"extension":1062,"image":1063,"imageAlt":1063,"meta":5434,"navigation":305,"path":5435,"seo":5436,"stem":5437,"tags":5438,"__hash__":5440},"blog\u002Fblog\u002Fcloudflare-r2-stockage-objets-sans-frais-de-sortie.md","Cloudflare R2 : du stockage objet sans frais de sortie pour vos applications",{"type":8,"value":5131,"toc":5425},[5132,5139,5143,5150,5154,5161,5165,5168,5266,5273,5386,5389,5393,5417,5419,5422],[11,5133,5134,5135,5138],{},"Quand une application doit stocker des fichiers utilisateurs (documents,\nimages, PDF), le réflexe est souvent Amazon S3. Mais pour un projet dont le\nbudget se compte en euros par mois plutôt qu'en milliers, la facture S3\ncache un piège : les ",[191,5136,5137],{},"frais de sortie"," (egress). Chaque téléchargement de\nfichier par vos utilisateurs vous coûte de la bande passante facturée.\nC'est précisément ce que Cloudflare R2 élimine.",[15,5140,5142],{"id":5141},"ce-quest-r2-en-une-phrase","Ce qu'est R2, en une phrase",[11,5144,5145,5146,5149],{},"R2 est un stockage objet compatible avec l'API S3 (mêmes SDK, mêmes\nconcepts de buckets et de clés), avec une différence économique majeure :\n",[191,5147,5148],{},"zéro frais de sortie",". Vous payez le stockage (environ 0,015 $\u002FGo\u002Fmois)\net les opérations, pas les téléchargements.",[15,5151,5153],{"id":5152},"pourquoi-cest-décisif-pour-une-app-à-documents","Pourquoi c'est décisif pour une app à documents",[11,5155,5156,5157,5160],{},"Prenons une application de dossiers médicaux où les utilisateurs consultent\nrégulièrement leurs documents. Le ratio lecture\u002Fécriture est très\ndéséquilibré : un document uploadé une fois sera téléchargé des dizaines de\nfois. Sur S3, ce trafic de lecture est le poste de coût principal et,\nsurtout, il est ",[191,5158,5159],{},"imprévisible"," : il croît avec l'usage, pas avec le\nvolume stocké. Sur R2, le coût est fonction du seul volume : prévisible,\nbudgétable, sans mauvaise surprise en cas de succès.",[15,5162,5164],{"id":5163},"lintégration-côté-nestjs","L'intégration côté NestJS",[11,5166,5167],{},"R2 étant compatible S3, le SDK AWS officiel fonctionne tel quel, seul\nl'endpoint change :",[78,5169,5171],{"className":80,"code":5170,"language":82,"meta":83,"style":83},"import { S3Client } from '@aws-sdk\u002Fclient-s3';\n\nconst r2 = new S3Client({\n  region: 'auto',\n  endpoint: `https:\u002F\u002F${accountId}.r2.cloudflarestorage.com`,\n  credentials: {\n    accessKeyId: process.env.R2_ACCESS_KEY_ID,\n    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,\n  },\n});\n",[20,5172,5173,5187,5191,5207,5217,5233,5238,5248,5258,5262],{"__ignoreMap":83},[87,5174,5175,5177,5180,5182,5185],{"class":89,"line":90},[87,5176,502],{"class":100},[87,5178,5179],{"class":111}," { S3Client } ",[87,5181,508],{"class":100},[87,5183,5184],{"class":162}," '@aws-sdk\u002Fclient-s3'",[87,5186,3162],{"class":111},[87,5188,5189],{"class":89,"line":97},[87,5190,306],{"emptyLinePlaceholder":305},[87,5192,5193,5195,5198,5200,5202,5205],{"class":89,"line":128},[87,5194,815],{"class":100},[87,5196,5197],{"class":137}," r2",[87,5199,527],{"class":100},[87,5201,530],{"class":100},[87,5203,5204],{"class":107}," S3Client",[87,5206,593],{"class":111},[87,5208,5209,5212,5215],{"class":89,"line":153},[87,5210,5211],{"class":111},"  region: ",[87,5213,5214],{"class":162},"'auto'",[87,5216,1124],{"class":111},[87,5218,5219,5222,5225,5228,5231],{"class":89,"line":177},[87,5220,5221],{"class":111},"  endpoint: ",[87,5223,5224],{"class":162},"`https:\u002F\u002F${",[87,5226,5227],{"class":111},"accountId",[87,5229,5230],{"class":162},"}.r2.cloudflarestorage.com`",[87,5232,1124],{"class":111},[87,5234,5235],{"class":89,"line":309},[87,5236,5237],{"class":111},"  credentials: {\n",[87,5239,5240,5243,5246],{"class":89,"line":320},[87,5241,5242],{"class":111},"    accessKeyId: process.env.",[87,5244,5245],{"class":137},"R2_ACCESS_KEY_ID",[87,5247,1124],{"class":111},[87,5249,5250,5253,5256],{"class":89,"line":330},[87,5251,5252],{"class":111},"    secretAccessKey: process.env.",[87,5254,5255],{"class":137},"R2_SECRET_ACCESS_KEY",[87,5257,1124],{"class":111},[87,5259,5260],{"class":89,"line":350},[87,5261,4334],{"class":111},[87,5263,5264],{"class":89,"line":355},[87,5265,3409],{"class":111},[11,5267,5268,5269,5272],{},"Le pattern que je recommande pour les fichiers privés : les ",[191,5270,5271],{},"URLs\nprésignées",". Le backend ne sert jamais les octets lui-même : il génère une\nURL temporaire (quelques minutes de validité) que le client utilise pour\nuploader ou télécharger directement auprès de R2 :",[78,5274,5276],{"className":80,"code":5275,"language":82,"meta":83,"style":83},"import { GetObjectCommand } from '@aws-sdk\u002Fclient-s3';\nimport { getSignedUrl } from '@aws-sdk\u002Fs3-request-presigner';\n\nasync getDownloadUrl(key: string): Promise\u003Cstring> {\n  const command = new GetObjectCommand({ Bucket: 'documents', Key: key });\n  return getSignedUrl(this.r2, command, { expiresIn: 300 });\n}\n",[20,5277,5278,5291,5305,5309,5333,5354,5382],{"__ignoreMap":83},[87,5279,5280,5282,5285,5287,5289],{"class":89,"line":90},[87,5281,502],{"class":100},[87,5283,5284],{"class":111}," { GetObjectCommand } ",[87,5286,508],{"class":100},[87,5288,5184],{"class":162},[87,5290,3162],{"class":111},[87,5292,5293,5295,5298,5300,5303],{"class":89,"line":97},[87,5294,502],{"class":100},[87,5296,5297],{"class":111}," { getSignedUrl } ",[87,5299,508],{"class":100},[87,5301,5302],{"class":162}," '@aws-sdk\u002Fs3-request-presigner'",[87,5304,3162],{"class":111},[87,5306,5307],{"class":89,"line":128},[87,5308,306],{"emptyLinePlaceholder":305},[87,5310,5311,5314,5317,5320,5323,5325,5328,5331],{"class":89,"line":153},[87,5312,5313],{"class":111},"async ",[87,5315,5316],{"class":107},"getDownloadUrl",[87,5318,5319],{"class":111},"(key: string): ",[87,5321,5322],{"class":137},"Promise",[87,5324,792],{"class":100},[87,5326,5327],{"class":111},"string",[87,5329,5330],{"class":100},">",[87,5332,125],{"class":111},[87,5334,5335,5338,5340,5342,5345,5348,5351],{"class":89,"line":177},[87,5336,5337],{"class":111},"  const command ",[87,5339,144],{"class":100},[87,5341,530],{"class":100},[87,5343,5344],{"class":107}," GetObjectCommand",[87,5346,5347],{"class":111},"({ Bucket: ",[87,5349,5350],{"class":162},"'documents'",[87,5352,5353],{"class":111},", Key: key });\n",[87,5355,5356,5359,5362,5365,5368,5371,5374,5376,5379],{"class":89,"line":309},[87,5357,5358],{"class":111},"  return ",[87,5360,5361],{"class":107},"getSignedUrl",[87,5363,5364],{"class":111},"(this.r2, ",[87,5366,5367],{"class":115},"command",[87,5369,5370],{"class":111},", { ",[87,5372,5373],{"class":115},"expiresIn",[87,5375,823],{"class":111},[87,5377,5378],{"class":137},"300",[87,5380,5381],{"class":111}," });\n",[87,5383,5384],{"class":89,"line":320},[87,5385,300],{"class":111},[11,5387,5388],{},"Bénéfices : votre serveur applicatif (un petit VPS, par exemple) n'est\njamais le goulot d'étranglement des transferts de fichiers, et le contrôle\nd'accès reste entièrement chez vous : c'est votre API qui décide qui obtient\nune URL, et pour combien de temps.",[15,5390,5392],{"id":5391},"les-pièges-à-connaître","Les pièges à connaître",[38,5394,5395,5401,5411],{},[41,5396,5397,5400],{},[191,5398,5399],{},"La compatibilité S3 n'est pas totale."," L'essentiel y est (put, get,\nmultipart, presign), mais certaines fonctionnalités avancées de S3\nmanquent. Vérifiez la liste officielle si vous migrez un code existant.",[41,5402,5403,5406,5407,5410],{},[191,5404,5405],{},"Nommez vos clés avec discipline"," dès le premier jour\n(",[20,5408,5409],{},"users\u002F{id}\u002Fdocuments\u002F{uuid}.pdf",") : le stockage objet n'a pas de\ndossiers, et une convention de nommage propre est votre seul outil\nd'organisation et de purge.",[41,5412,5413,5416],{},[191,5414,5415],{},"Ne mettez jamais de données sensibles dans les noms de clés"," : ils\napparaissent dans les URLs et les logs.",[15,5418,1038],{"id":1037},[11,5420,5421],{},"Pour une application qui sert beaucoup de fichiers avec un budget contraint,\nle critère n'est pas « quel est le stockage le moins cher au Go » mais\n« quel est le coût total quand mes utilisateurs utilisent vraiment\nl'application ». R2 rend ce coût prévisible, et pour un produit qui doit\nvivre des années, la prévisibilité est une fonctionnalité.",[1046,5423,5424],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":83,"searchDepth":97,"depth":97,"links":5426},[5427,5428,5429,5430,5431],{"id":5141,"depth":97,"text":5142},{"id":5152,"depth":97,"text":5153},{"id":5163,"depth":97,"text":5164},{"id":5391,"depth":97,"text":5392},{"id":1037,"depth":97,"text":1038},"2026-05-12","Pourquoi R2 plutôt que S3 pour stocker les fichiers d'une application : architecture, URLs présignées avec NestJS et pièges à éviter.",{},"\u002Fblog\u002Fcloudflare-r2-stockage-objets-sans-frais-de-sortie",{"title":5129,"description":5433},"blog\u002Fcloudflare-r2-stockage-objets-sans-frais-de-sortie",[5439,3601,3105],"Cloud","x4LMwsQCX7dcwVdTSyZl61kUaQH9SCb4mhcUHa3nWh4",1784652014639]