[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-vue-weekly-watch-vs-watcheffect":44,"search-suggestions":60,"quiz-article-vue-weekly-watch-vs-watcheffect":107},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a0ec73-2f40-7369-a7f5-edde626bf628","vue-weekly-watch-vs-watcheffect","PRACTICE_QUIZ","Vue watch() vs watchEffect()","Nine questions on when each watcher actually runs, how flush timing changes what a callback reads, and how to cancel a stale async run before it overwrites a newer one.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},9,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,101,104],{"slug":62,"name":63,"articles":64},"webdev","Webdev",137,{"slug":66,"name":67,"articles":68},"javascript","Javascript",112,{"slug":70,"name":71,"articles":72},"frontend","Frontend",83,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",53,{"slug":78,"name":79,"articles":80},"css","Css",42,{"slug":82,"name":83,"articles":84},"performance","Performance",20,{"slug":86,"name":87,"articles":88},"typescript","Typescript",18,{"slug":90,"name":91,"articles":92},"react","React",17,{"slug":94,"name":95,"articles":96},"browser","Browser",13,{"slug":98,"name":99,"articles":100},"node","Node",12,{"slug":102,"name":103,"articles":51},"programming","Programming",{"slug":105,"name":106,"articles":51},"accessibility","Accessibility",{"id":108,"slug":46,"title":109,"subtitle":52,"excerpt":110,"coverUrl":111,"locale":13,"readingMinutes":112,"publishedAt":113,"viewCount":114,"likeCount":19,"commentCount":19,"author":115,"vertical":120,"topic":121,"tags":124,"_count":129,"playground":131,"body":133,"bodyMd":490,"seo":491,"translationGroupId":493,"series":494,"podcastUrl":52,"verticalId":5,"thread":520,"assessments":522,"translations":525,"quiz":527},"01a0ec73-2e41-711f-9f8a-b6c9bae1e054","Vue watch vs watchEffect: The Complete Guide","Vue's watch() and watchEffect() look interchangeable but track dependencies, run timing, and cleanup differently. The exact rules, verified against Vue 3.5.","\u002Fmedia\u002Fcovers\u002Fvue-weekly-watch-vs-watcheffect.png",15,"2026-10-12T06:10:54.504Z",116,{"id":116,"name":117,"username":118,"avatarUrl":52,"headline":119},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":122,"name":123},"vue","Vue",[125,126,127,128],{"slug":122,"name":123,"color":52},{"slug":66,"name":67,"color":52},{"slug":74,"name":75,"color":52},{"slug":62,"name":63,"color":52},{"assessments":130},1,{"slug":46,"title":132},"Vue watch vs watchEffect — race condition playground",{"blocks":134,"version":130},[135,139,142,147,150,159,162,165,168,171,188,191,194,199,202,205,208,211,216,219,222,227,230,233,237,240,243,246,249,253,256,260,263,266,269,273,276,279,282,301,304,308,311,314,317,321,324,327,331,334,337,340,344,347,350,358,361,369,372,375,378,382,385,389,392,395,398,401,404,407,410,413,417,420,427,430,433,436,439,442,445,448,451,454,457,460,463,466,472,475,478,481,484],{"id":136,"html":137,"type":138},"b1","\u003Cp>You type &quot;vue&quot; into a search box, one letter at a time. Four requests go out — for \u003Ccode>v\u003C\u002Fcode>, \u003Ccode>vu\u003C\u002Fcode>, \u003Ccode>vue\u003C\u002Fcode>, and the deleted-and-retyped \u003Ccode>vue\u003C\u002Fcode> again — and the one for \u003Ccode>v\u003C\u002Fcode> happens to come back last. The screen ends up showing results for a single letter, on top of a \u003Ccode>watch()\u003C\u002Fcode> callback that looks completely correct. Nothing in that callback is wrong. The watcher just never learned that a newer run had already made it obsolete.\u003C\u002Fp>","paragraph",{"id":140,"html":141,"type":138},"b2","\u003Cp>This is the article that fixes that, and along the way answers the question every Vue developer eventually asks: \u003Cstrong>when do I reach for \u003Ccode>watch()\u003C\u002Fcode>, when do I reach for \u003Ccode>watchEffect()\u003C\u002Fcode>, and why does my callback keep firing at the wrong moment?\u003C\u002Fstrong>\u003C\u002Fp>",{"id":143,"html":144,"text":145,"type":146,"level":31},"b3","What you&#39;ll learn","What you'll learn","heading",{"id":148,"html":149,"type":138},"b4","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":151,"type":152,"items":153,"ordered":18},"b5","list",[154,155,156,157,158],"Explain the real difference between \u003Ccode>watch()\u003C\u002Fcode> and \u003Ccode>watchEffect()\u003C\u002Fcode> — not &quot;one is explicit, one is automatic,&quot; but \u003Cem>what that difference costs you\u003C\u002Fem>","Pick the correct \u003Ccode>flush\u003C\u002Fcode> timing (\u003Ccode>pre\u003C\u002Fcode>, \u003Ccode>post\u003C\u002Fcode>, \u003Ccode>sync\u003C\u002Fcode>) for a watcher that needs to read the DOM","Use \u003Ccode>onWatcherCleanup\u003C\u002Fcode> to cancel a stale async response before it overwrites a newer one","Stop a watcher manually, and know when Vue stops it for you","Recognize the specific ways both APIs quietly track (or fail to track) the wrong things",{"id":160,"html":161,"text":161,"type":146,"level":31},"b6","Prerequisites",{"id":163,"html":164,"type":138},"b7","\u003Cp>You should already be comfortable with \u003Ccode>ref()\u003C\u002Fcode>, \u003Ccode>reactive()\u003C\u002Fcode>, and \u003Ccode>computed()\u003C\u002Fcode> in the Composition API. If &quot;why does destructuring a \u003Ccode>reactive()\u003C\u002Fcode> object lose reactivity&quot; isn&#39;t yet second nature, read \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij\">Vue Reactivity Explained: ref vs reactive\u003C\u002Fa> first — this article assumes that mental model and builds on it.\u003C\u002Fp>",{"id":166,"html":167,"type":138},"b8","\u003Cp>This is written against \u003Cstrong>Vue 3.5.x\u003C\u002Fstrong> (verified September 2026). Version-specific notes (the \u003Ccode>once\u003C\u002Fcode> option, \u003Ccode>onWatcherCleanup\u003C\u002Fcode>) are called out where they apply.\u003C\u002Fp>",{"id":169,"html":170,"text":170,"type":146,"level":31},"b9","Table of contents",{"id":172,"type":152,"items":173,"ordered":18},"b10",[174,175,176,177,178,179,180,181,182,183,184,185,186,187],"\u003Ca href=\"#the-problem-a-watcher-with-no-memory\">The problem: a watcher with no memory\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-pull-vs-push-tracking\">The mental model: pull vs. push tracking\u003C\u002Fa>","\u003Ca href=\"#stage-1-the-smallest-watch\">Stage 1: the smallest watch()\u003C\u002Fa>","\u003Ca href=\"#stage-2-the-same-job-with-watcheffect\">Stage 2: the same job with watchEffect()\u003C\u002Fa>","\u003Ca href=\"#stage-3-multiple-sources-and-watching-inside-an-object\">Stage 3: multiple sources, and watching inside an object\u003C\u002Fa>","\u003Ca href=\"#stage-4-immediate-deep-and-once\">Stage 4: immediate, deep, and once\u003C\u002Fa>","\u003Ca href=\"#stage-5-flush-timing--pre-post-sync\">Stage 5: flush timing — pre, post, sync\u003C\u002Fa>","\u003Ca href=\"#stage-6-cleanup-and-the-race-condition-fixed\">Stage 6: cleanup and the race condition, fixed\u003C\u002Fa>","\u003Ca href=\"#stopping-a-watcher\">Stopping a watcher\u003C\u002Fa>","\u003Ca href=\"#edge-cases-and-gotchas\">Edge cases and gotchas\u003C\u002Fa>","\u003Ca href=\"#best-practices\">Best practices\u003C\u002Fa>","\u003Ca href=\"#faq\">FAQ\u003C\u002Fa>","\u003Ca href=\"#cheat-sheet\">Cheat sheet\u003C\u002Fa>","\u003Ca href=\"#key-takeaways\">Key takeaways\u003C\u002Fa>",{"id":189,"html":190,"text":190,"type":146,"level":31},"b11","The problem: a watcher with no memory",{"id":192,"html":193,"type":138},"b12","\u003Cp>Here&#39;s the search box, written the way it&#39;s tempting to write it the first time:\u003C\u002Fp>",{"id":195,"code":196,"type":197,"language":122,"highlight":198},"b13","\u003Cscript setup>\nimport { ref, watch } from \"vue\";\n\nconst query = ref(\"\");\nconst results = ref([]);\n\nwatch(query, async (q) => {\n  if (!q) {\n    results.value = [];\n    return;\n  }\n  const res = await fetch(`\u002Fapi\u002Fsearch?q=${encodeURIComponent(q)}`);\n  results.value = await res.json();\n});\n\u003C\u002Fscript>","code",[],{"id":200,"html":201,"type":138},"b14","\u003Cp>Every keystroke fires a new request, and requests do not resolve in the order they were sent. If the request for \u003Ccode>v\u003C\u002Fcode> is slow and the request for \u003Ccode>vue\u003C\u002Fcode> is fast, \u003Ccode>vue\u003C\u002Fcode>&#39;s results land first and \u003Ccode>v\u003C\u002Fcode>&#39;s results land \u003Cem>after\u003C\u002Fem>, silently overwriting them. The callback did exactly what it was written to do; it just never asked &quot;am I still the most recent run?&quot;\u003C\u002Fp>",{"id":203,"html":204,"type":138},"b15","\u003Cp>That question is exactly what a watcher&#39;s cleanup mechanism answers. Getting there first requires being precise about what \u003Ccode>watch()\u003C\u002Fcode> and \u003Ccode>watchEffect()\u003C\u002Fcode> actually are.\u003C\u002Fp>",{"id":206,"html":207,"text":207,"type":146,"level":31},"b16","The mental model: pull vs. push tracking",{"id":209,"html":210,"type":138},"b17","\u003Cp>\u003Cstrong>The mental model:\u003C\u002Fstrong> \u003Ccode>watch()\u003C\u002Fcode> is \u003Cem>told\u003C\u002Fem> what to watch. \u003Ccode>watchEffect()\u003C\u002Fcode> \u003Cem>discovers\u003C\u002Fem> what to watch by running once and recording every reactive value it reads. Everything else — old\u002Fnew values, laziness, immediacy — falls out of that one distinction.\u003C\u002Fp>",{"id":212,"type":152,"items":213,"ordered":18},"b18",[214,215],"\u003Cstrong>\u003Ccode>watch(source, callback)\u003C\u002Fcode>\u003C\u002Fstrong> takes an explicit source (a ref, a getter, a \u003Ccode>reactive()\u003C\u002Fcode> object, or an array of them) and a callback. It does \u003Cstrong>not\u003C\u002Fstrong> run on creation by default. It runs only when the \u003Cem>named\u003C\u002Fem> source changes, and it hands the callback both the new and the old value.","\u003Cstrong>\u003Ccode>watchEffect(fn)\u003C\u002Fcode>\u003C\u002Fstrong> takes a single function with no separate source. It runs \u003Cstrong>immediately\u003C\u002Fstrong>, once, synchronously (well — see flush timing below) to figure out what it read, and re-runs itself in full whenever any of those dependencies change. There is no &quot;old value&quot; concept, because there&#39;s no fixed source to diff against — the whole function reruns.",{"id":217,"html":218,"type":138},"b19","\u003Cp>Put differently: \u003Ccode>watch\u003C\u002Fcode> is a subscription to a specific address. \u003Ccode>watchEffect\u003C\u002Fcode> is a subscription to \u003Cem>whatever addresses this function happens to visit today\u003C\u002Fem> — and it can visit different addresses on different runs, exactly like a \u003Ccode>computed()\u003C\u002Fcode> getter does. The dependency-tracking mechanism (a run records every reactive value it reads) is shared across \u003Ccode>computed\u003C\u002Fcode>, \u003Ccode>watch\u003C\u002Fcode>&#39;s getter-form sources, and \u003Ccode>watchEffect\u003C\u002Fcode> alike.\u003C\u002Fp>",{"id":220,"html":221,"text":221,"type":146,"level":31},"b20","Stage 1: the smallest watch()",{"id":223,"code":224,"type":197,"language":225,"highlight":226},"b21","import { ref, watch } from \"vue\";\n\nconst count = ref(0);\n\nwatch(count, (newVal, oldVal) => {\n  console.log(`count went from ${oldVal} to ${newVal}`);\n});\n\ncount.value++; \u002F\u002F logs: \"count went from 0 to 1\"","ts",[],{"id":228,"html":229,"type":138},"b22","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> the callback did not run when \u003Ccode>watch\u003C\u002Fcode> was declared — only after \u003Ccode>count\u003C\u002Fcode> changed. That&#39;s the &quot;lazy by default&quot; behavior, and it&#39;s the first thing that trips people up coming from \u003Ccode>watchEffect\u003C\u002Fcode>.\u003C\u002Fp>",{"id":231,"html":232,"text":232,"type":146,"level":31},"b23","Stage 2: the same job with watchEffect()",{"id":234,"code":235,"type":197,"language":225,"highlight":236},"b24","import { ref, watchEffect } from \"vue\";\n\nconst count = ref(0);\n\nwatchEffect(() => {\n  console.log(`count is now ${count.value}`);\n});\n\u002F\u002F logs immediately: \"count is now 0\"\n\ncount.value++; \u002F\u002F logs: \"count is now 1\"",[],{"id":238,"html":239,"type":138},"b25","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> \u003Ccode>watchEffect\u003C\u002Fcode> ran once immediately, on creation, before anything changed — because it has to run once just to discover that \u003Ccode>count\u003C\u002Fcode> is a dependency. \u003Ccode>watch\u003C\u002Fcode> never needed to run to know its source; you named it.\u003C\u002Fp>",{"id":241,"html":242,"type":138},"b26","\u003Cp>If the job is &quot;sync this state to something external, right now and on every future change&quot; — a document title, a \u003Ccode>localStorage\u003C\u002Fcode> key, an analytics call — \u003Ccode>watchEffect\u003C\u002Fcode> is usually the shorter answer. Need the \u003Cem>old\u003C\u002Fem> value, or need to stay silent until one specific thing changes? Reach for \u003Ccode>watch\u003C\u002Fcode>.\u003C\u002Fp>",{"id":244,"html":245,"text":245,"type":146,"level":31},"b27","Stage 3: multiple sources, and watching inside an object",{"id":247,"html":248,"type":138},"b28","\u003Cp>\u003Ccode>watch\u003C\u002Fcode> accepts an array of sources, and hands the callback matching arrays of new\u002Fold values:\u003C\u002Fp>",{"id":250,"code":251,"type":197,"language":225,"highlight":252},"b29","watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {\n  console.log(`${oldFirst} ${oldLast} -> ${newFirst} ${newLast}`);\n});",[],{"id":254,"html":255,"type":138},"b30","\u003Cp>Watching a single property of a \u003Ccode>reactive()\u003C\u002Fcode> object requires a \u003Cstrong>getter function\u003C\u002Fstrong>, not the bare property — because \u003Ccode>reactive().someProp\u003C\u002Fcode> is just a plain value the instant you read it out of the object, with no reference back to the proxy:\u003C\u002Fp>",{"id":257,"code":258,"type":197,"language":225,"highlight":259},"b31","const state = reactive({ count: 0, name: \"vue\" });\n\n\u002F\u002F Wrong: this is a snapshot number, not a reactive source.\n\u002F\u002F watch(state.count, cb) never fires.\n\n\u002F\u002F Correct: a getter re-reads the proxy every time Vue checks it.\nwatch(\n  () => state.count,\n  (newCount, oldCount) => console.log(newCount, oldCount)\n);",[],{"id":261,"html":262,"type":138},"b32","\u003Cp>This is the same &quot;reactivity is a subscription made at read time&quot; rule from the \u003Ccode>ref\u003C\u002Fcode> vs \u003Ccode>reactive\u003C\u002Fcode> article — \u003Ccode>watch\u003C\u002Fcode> needs something it can re-read, and a getter is how you hand it that.\u003C\u002Fp>",{"id":264,"html":265,"text":265,"type":146,"level":31},"b33","Stage 4: immediate, deep, and once",{"id":267,"html":268,"type":138},"b34","\u003Cp>Three options on \u003Ccode>watch\u003C\u002Fcode> change when and how often it fires (\u003Ccode>watchEffect\u003C\u002Fcode> accepts only \u003Ccode>flush\u003C\u002Fcode>, covered next, plus the dev-only \u003Ccode>onTrack\u003C\u002Fcode>\u002F\u003Ccode>onTrigger\u003C\u002Fcode> — it runs on creation and tracks everything it reads, so \u003Ccode>immediate\u003C\u002Fcode>, \u003Ccode>deep\u003C\u002Fcode> and \u003Ccode>once\u003C\u002Fcode> don&#39;t apply):\u003C\u002Fp>",{"id":270,"code":271,"type":197,"language":225,"highlight":272},"b35","watch(source, callback, {\n  immediate: true, \u002F\u002F also run once, right away, with oldValue undefined\n  deep: true,      \u002F\u002F walk the whole object\u002Farray, not just the top-level reference\n  once: true,      \u002F\u002F (Vue 3.4+) auto-stop after the callback fires the first time\n});",[],{"id":274,"html":275,"type":138},"b36","\u003Cp>\u003Ccode>deep: true\u003C\u002Fcode> is the one to use sparingly. Watching a getter that returns an object (\u003Ccode>() =&gt; state.someObject\u003C\u002Fcode>) or a \u003Ccode>ref\u003C\u002Fcode> holding one only fires when the whole object is replaced (a \u003Ccode>reactive()\u003C\u002Fcode> object passed directly is already implicitly deep); \u003Ccode>deep\u003C\u002Fcode> walks every nested property so a change three levels down also fires — at the cost of Vue actually traversing the structure to set that tracking up. On a large or deeply nested object, that traversal is real work.\u003C\u002Fp>",{"id":277,"html":278,"text":278,"type":146,"level":31},"b37","Stage 5: flush timing — pre, post, sync",{"id":280,"html":281,"type":138},"b38","\u003Cp>Both \u003Ccode>watch\u003C\u002Fcode> and \u003Ccode>watchEffect\u003C\u002Fcode> accept \u003Ccode>flush\u003C\u002Fcode>, and it answers a different question than the options above: \u003Cem>relative to a component&#39;s own re-render, when does this callback run?\u003C\u002Fem>\u003C\u002Fp>",{"id":283,"head":284,"rows":288,"type":300},"b39",[285,286,287],"\u003Ccode>flush\u003C\u002Fcode> value","When it runs","Default?",[289,293,297],[290,291,292],"\u003Ccode>&#39;pre&#39;\u003C\u002Fcode>","Before the component re-renders","Yes, for both APIs",[294,295,296],"\u003Ccode>&#39;post&#39;\u003C\u002Fcode>","After the component has re-rendered and the DOM is updated","No",[298,299,296],"\u003Ccode>&#39;sync&#39;\u003C\u002Fcode>","Synchronously, the instant the dependency is written","table",{"id":302,"html":303,"type":138},"b40","\u003Cp>This connects directly to the batching behavior from \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-nexttick-explained-how-dom-updates-are-batched-3gnp\">Vue nextTick Explained\u003C\u002Fa>: a \u003Ccode>pre\u003C\u002Fcode>-flush watcher (the default) can run \u003Cem>before\u003C\u002Fem> the DOM reflects the change that triggered it. If your callback needs to measure or read an element that just changed size or content, \u003Ccode>flush: &#39;pre&#39;\u003C\u002Fcode> will read stale layout:\u003C\u002Fp>",{"id":305,"code":306,"type":197,"language":225,"highlight":307},"b41","watch(\n  items,\n  () => {\n    \u002F\u002F With the default 'pre' flush, the DOM here may not yet\n    \u002F\u002F reflect the new `items` — this measurement can be one step behind.\n    console.log(listRef.value.scrollHeight);\n  },\n  { flush: \"post\" } \u002F\u002F wait for the DOM update first\n);",[],{"id":309,"html":310,"type":138},"b42","\u003Cp>\u003Ccode>flush: &#39;sync&#39;\u003C\u002Fcode> skips Vue&#39;s batching queue entirely and runs the instant the dependency is written — useful for invalidating an external cache the moment something changes, but dangerous on sources mutated many times synchronously (e.g. an array filled in a loop), since it fires on every single mutation with no batching.\u003C\u002Fp>",{"id":312,"html":313,"text":313,"type":146,"level":31},"b43","Stage 6: cleanup and the race condition, fixed",{"id":315,"html":316,"type":138},"b44","\u003Cp>Back to the search box. The fix is to give each run a way to say &quot;abort me if a newer run has already started&quot; — and both \u003Ccode>watch\u003C\u002Fcode> and \u003Ccode>watchEffect\u003C\u002Fcode> give you exactly that, as \u003Ccode>onWatcherCleanup()\u003C\u002Fcode> (3.5+), which must be called \u003Cstrong>synchronously\u003C\u002Fstrong> during the callback (before any \u003Ccode>await\u003C\u002Fcode>):\u003C\u002Fp>",{"id":318,"code":319,"type":197,"language":122,"highlight":320},"b45","\u003Cscript setup>\nimport { ref, watch, onWatcherCleanup } from \"vue\"; \u002F\u002F onWatcherCleanup: Vue 3.5+\n\nconst query = ref(\"\");\nconst results = ref([]);\n\nwatch(query, async (q) => {\n  if (!q) {\n    results.value = [];\n    return;\n  }\n\n  const controller = new AbortController();\n  onWatcherCleanup(() => controller.abort());\n\n  try {\n    const res = await fetch(`\u002Fapi\u002Fsearch?q=${encodeURIComponent(q)}`, {\n      signal: controller.signal,\n    });\n    results.value = await res.json();\n  } catch (e) {\n    if (e.name !== \"AbortError\") throw e; \u002F\u002F an aborted run is expected, not an error\n  }\n});\n\u003C\u002Fscript>",[],{"id":322,"html":323,"type":138},"b46","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> \u003Ccode>onWatcherCleanup\u003C\u002Fcode> registers a function that Vue calls \u003Cem>right before the next run of this same watcher\u003C\u002Fem> — and also when the component unmounts. Type \u003Ccode>vue\u003C\u002Fcode>, and the moment the \u003Ccode>e\u003C\u002Fcode> keystroke starts a new run, the previous run&#39;s cleanup fires first, aborting its in-flight fetch. An aborted request rejects with an \u003Ccode>AbortError\u003C\u002Fcode> instead of resolving, so it can never reach \u003Ccode>results.value\u003C\u002Fcode> — and the \u003Ccode>try\u003C\u002Fcode>\u002F\u003Ccode>catch\u003C\u002Fcode> swallows just that expected rejection, which would otherwise surface through Vue&#39;s error handler on every superseded keystroke. Order of arrival stops mattering, because only the current, un-aborted request is allowed to write.\u003C\u002Fp>",{"id":325,"html":326,"type":138},"b47","\u003Cp>The same mechanism is also passed as the callback&#39;s third parameter (the only option before 3.5, and still supported):\u003C\u002Fp>",{"id":328,"code":329,"type":197,"language":225,"highlight":330},"b48","watch(query, async (q, oldQ, onCleanup) => {\n  const controller = new AbortController();\n  onCleanup(() => controller.abort());\n  \u002F\u002F ...\n});",[],{"id":332,"html":333,"type":138},"b49","\u003Cp>The two forms differ here: \u003Ccode>onWatcherCleanup\u003C\u002Fcode> must be called before the first \u003Ccode>await\u003C\u002Fcode> — after it, Vue can&#39;t tell which watcher run it belongs to, so dev mode warns and the cleanup is silently dropped. The \u003Ccode>onCleanup\u003C\u002Fcode> argument is bound to its watcher, so it isn&#39;t subject to that constraint.\u003C\u002Fp>",{"id":335,"html":336,"text":336,"type":146,"level":31},"b50","Stopping a watcher",{"id":338,"html":339,"type":138},"b51","\u003Cp>Both \u003Ccode>watch\u003C\u002Fcode> and \u003Ccode>watchEffect\u003C\u002Fcode> return a handle — call it (or \u003Ccode>.stop()\u003C\u002Fcode>) to stop; since 3.5 it also has \u003Ccode>.pause()\u003C\u002Fcode> \u002F \u003Ccode>.resume()\u003C\u002Fcode>:\u003C\u002Fp>",{"id":341,"code":342,"type":197,"language":225,"highlight":343},"b52","const stop = watchEffect(() => {\n  console.log(count.value);\n});\n\nstop(); \u002F\u002F this effect will never run again",[],{"id":345,"html":346,"type":138},"b53","\u003Cp>You rarely need to call this yourself inside a component: a watcher created \u003Cstrong>synchronously\u003C\u002Fstrong> during \u003Ccode>setup()\u003C\u002Fcode> or \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode> is automatically stopped when the component unmounts; one created later from an async callback (a \u003Ccode>setTimeout\u003C\u002Fcode>, a \u003Ccode>.then\u003C\u002Fcode>) inside that same \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode> is not. You need the manual \u003Ccode>stop()\u003C\u002Fcode> mostly for watchers created outside a component&#39;s setup — inside a composable that runs after mount, in a store, or in plain JS — where Vue has no unmount to tie it to.\u003C\u002Fp>",{"id":348,"html":349,"text":349,"type":146,"level":31},"b54","Edge cases and gotchas",{"id":351,"type":152,"items":352,"ordered":18},"b55",[353,354,355,356,357],"\u003Cstrong>\u003Ccode>await\u003C\u002Fcode> breaks dependency discovery.\u003C\u002Fstrong> \u003Ccode>watchEffect\u003C\u002Fcode> only tracks reads that happen synchronously before the first \u003Ccode>await\u003C\u002Fcode>. Anything read after an \u003Ccode>await\u003C\u002Fcode> inside the function is invisible to it and will not trigger a re-run if it changes.","\u003Cstrong>A getter that reads nothing reactive never fires.\u003C\u002Fstrong> \u003Ccode>watch(() =&gt; someObj.plainProperty, cb)\u003C\u002Fcode> looks correct but never runs if \u003Ccode>plainProperty\u003C\u002Fcode> isn&#39;t itself reactive — there&#39;s nothing for Vue to subscribe to.","\u003Cstrong>\u003Ccode>watchEffect\u003C\u002Fcode> can over-track.\u003C\u002Fstrong> Because it tracks everything the function body reads, a large conditional inside it can pick up dependencies you didn&#39;t intend, causing extra re-runs. \u003Ccode>watch\u003C\u002Fcode> with an explicit source avoids this by construction.","\u003Cstrong>\u003Ccode>.push\u003C\u002Fcode>\u002F\u003Ccode>.splice\u003C\u002Fcode> fire a non-deep watcher only when the source is the \u003Ccode>reactive()\u003C\u002Fcode> array itself\u003C\u002Fstrong> (implicitly deep). \u003Ccode>watch(itemsRef, cb)\u003C\u002Fcode> on a \u003Ccode>ref([])\u003C\u002Fcode>, or a getter like \u003Ccode>() =&gt; state.items\u003C\u002Fcode>, tracks only the array reference — in-place mutations need \u003Ccode>deep: true\u003C\u002Fcode>, or watch \u003Ccode>() =&gt; items.value.length\u003C\u002Fcode>.","\u003Cstrong>\u003Ccode>immediate: true\u003C\u002Fcode> hands \u003Ccode>undefined\u003C\u002Fcode> as \u003Ccode>oldValue\u003C\u002Fcode>\u003C\u002Fstrong> on that first synthetic run (an empty array \u003Ccode>[]\u003C\u002Fcode> when watching an array of sources) — always guard against it if the callback compares old and new.",{"id":359,"html":360,"text":360,"type":146,"level":31},"b56","Best practices",{"id":362,"type":152,"items":363,"ordered":18},"b57",[364,365,366,367,368],"\u003Cstrong>Reach for \u003Ccode>watchEffect\u003C\u002Fcode>\u003C\u002Fstrong> when the job is &quot;keep this side effect in sync with whatever it reads&quot; and you don&#39;t need the old value — document titles, \u003Ccode>localStorage\u003C\u002Fcode>, imperative DOM syncing.","\u003Cstrong>Reach for \u003Ccode>watch\u003C\u002Fcode>\u003C\u002Fstrong> when you need the previous value, need the callback to stay silent until one \u003Cem>specific\u003C\u002Fem> thing changes (not everything the function happens to read), or want lazy behavior by default.","\u003Cstrong>Default to \u003Ccode>flush: &#39;pre&#39;\u003C\u002Fcode>\u003C\u002Fstrong> (the default) unless the callback reads the DOM — then use \u003Ccode>&#39;post&#39;\u003C\u002Fcode>. Reserve \u003Ccode>&#39;sync&#39;\u003C\u002Fcode> for cache-invalidation-style work that must happen before Vue&#39;s own batching, and never on a source that&#39;s mutated many times in one tick, like an array.","\u003Cstrong>Always pair an async watcher with cleanup\u003C\u002Fstrong> the moment more than one run can be in flight at once — a search box, a resource fetched by an ID prop, anything driven by fast user input.","\u003Cstrong>Avoid \u003Ccode>deep: true\u003C\u002Fcode> on large objects\u003C\u002Fstrong> if you can watch a specific derived getter instead; the traversal cost is real.",{"id":370,"html":371,"text":371,"type":146,"level":31},"b58","FAQ",{"id":373,"html":374,"text":374,"type":146,"level":43},"b59","Does watchEffect run on component mount?",{"id":376,"html":377,"type":138},"b60","\u003Cp>It runs when it&#39;s created — during \u003Ccode>setup()\u003C\u002Fcode>, before mount — synchronously with the default \u003Ccode>flush: &#39;pre&#39;\u003C\u002Fcode>; with \u003Ccode>flush: &#39;post&#39;\u003C\u002Fcode> (\u003Ccode>watchPostEffect\u003C\u002Fcode>) even the first run waits until after render. That first run is also how it discovers its dependencies.\u003C\u002Fp>",{"id":379,"html":380,"text":381,"type":146,"level":43},"b61","What&#39;s the difference between watch&#39;s deep option and watchEffect?","What's the difference between watch's deep option and watchEffect?",{"id":383,"html":384,"type":138},"b62","\u003Cp>They&#39;re unrelated axes. \u003Ccode>deep\u003C\u002Fcode> controls how far into a \u003Cem>reactive object\u003C\u002Fem> \u003Ccode>watch\u003C\u002Fcode> traverses to detect a change. \u003Ccode>watchEffect\u003C\u002Fcode> always tracks only what it actually reads during a run, deep or shallow, because it has no fixed source to traverse — it just doesn&#39;t re-read nested properties unless your function body does.\u003C\u002Fp>",{"id":386,"html":387,"text":388,"type":146,"level":43},"b63","Why isn&#39;t my watch callback firing when I mutate a reactive object property?","Why isn't my watch callback firing when I mutate a reactive object property?",{"id":390,"html":391,"type":138},"b64","\u003Cp>The most common cause is watching a plain snapshot instead of a getter — see Stage 3. The second most common cause is watching a \u003Ccode>ref\u003C\u002Fcode> holding an object and mutating a nested property without \u003Ccode>deep: true\u003C\u002Fcode> (the object is reactive, but \u003Ccode>watch(ref)\u003C\u002Fcode> only tracks \u003Ccode>.value\u003C\u002Fcode>); the ref&#39;s own \u003Ccode>.value\u003C\u002Fcode> reference hasn&#39;t changed, so a shallow watch sees nothing.\u003C\u002Fp>",{"id":393,"html":394,"text":394,"type":146,"level":43},"b65","How do I stop a watcher manually?",{"id":396,"html":397,"type":138},"b66","\u003Cp>Call the function \u003Ccode>watch\u003C\u002Fcode>\u002F\u003Ccode>watchEffect\u003C\u002Fcode> returns: \u003Ccode>const stop = watch(...); stop();\u003C\u002Fcode>. For a watcher created synchronously inside \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode>, you rarely need to — unmount does it for you.\u003C\u002Fp>",{"id":399,"html":400,"text":400,"type":146,"level":43},"b67","Is watchEffect the same as a computed?",{"id":402,"html":403,"type":138},"b68","\u003Cp>No. \u003Ccode>computed()\u003C\u002Fcode> returns a cached \u003Cem>value\u003C\u002Fem> meant to be read elsewhere and must stay pure (no side effects). \u003Ccode>watchEffect\u003C\u002Fcode> runs a \u003Cem>side effect\u003C\u002Fem> and returns nothing meant to be read — it&#39;s for doing something, not deriving something.\u003C\u002Fp>",{"id":405,"html":406,"text":406,"type":146,"level":43},"b69","Does watch support async callbacks?",{"id":408,"html":409,"type":138},"b70","\u003Cp>Yes — \u003Ccode>async (newVal, oldVal) =&gt; { ... }\u003C\u002Fcode> works exactly as shown above. Just remember that \u003Ccode>oldVal\u003C\u002Fcode> and \u003Ccode>newVal\u003C\u002Fcode> are captured before the \u003Ccode>await\u003C\u002Fcode>, and that any \u003Ccode>onWatcherCleanup()\u003C\u002Fcode> call must happen before the first \u003Ccode>await\u003C\u002Fcode>.\u003C\u002Fp>",{"id":411,"html":412,"text":412,"type":146,"level":31},"b71","Cheat sheet",{"id":414,"code":415,"type":197,"language":225,"highlight":416},"b72","import { ref, reactive, watch, watchEffect, onWatcherCleanup } from \"vue\";\n\n\u002F\u002F watch(): explicit source, lazy by default, gives old + new value\nwatch(source, (newVal, oldVal) => { \u002F* ... *\u002F });\nwatch([a, b], ([newA, newB], [oldA, oldB]) => { \u002F* ... *\u002F });\nwatch(() => reactiveObj.prop, (newVal, oldVal) => { \u002F* ... *\u002F }); \u002F\u002F getter for a nested property\n\n\u002F\u002F watchEffect(): auto-tracked, runs immediately, no old value\nwatchEffect(() => { document.title = title.value; });\n\n\u002F\u002F Options (watch only, except flush which both accept)\nwatch(source, cb, {\n  immediate: true, \u002F\u002F also run once immediately (oldValue is undefined that time)\n  deep: true,      \u002F\u002F traverse nested properties, not just the top reference\n  once: true,      \u002F\u002F Vue 3.4+: auto-stop after first fire\n  flush: \"post\",   \u002F\u002F 'pre' (default) | 'post' (after DOM update) | 'sync' (no batching)\n});\n\n\u002F\u002F Cleanup: cancel a stale async run before the next one starts (Vue 3.5+)\nwatch(source, async (val) => {\n  const controller = new AbortController();\n  onWatcherCleanup(() => controller.abort()); \u002F\u002F before the first await\n  try {\n    await fetch(url, { signal: controller.signal });\n  } catch (e) {\n    if (e.name !== \"AbortError\") throw e;\n  }\n});\n\n\u002F\u002F Stopping\nconst stop = watchEffect(() => { \u002F* ... *\u002F });\nstop(); \u002F\u002F manual — usually unnecessary for watchers created synchronously in \u003Cscript setup>, which auto-stop on unmount\n\u002F\u002F Vue 3.5+: the handle also has stop.pause() \u002F stop.resume()",[],{"id":418,"html":419,"text":419,"type":146,"level":31},"b73","Key takeaways",{"id":421,"type":152,"items":422,"ordered":18},"b74",[423,424,425,426],"\u003Ccode>watch\u003C\u002Fcode> is told its source explicitly and is lazy by default; \u003Ccode>watchEffect\u003C\u002Fcode> discovers its sources by running once, immediately, and re-runs the whole function when any of them change.","\u003Ccode>flush\u003C\u002Fcode> decides \u003Cem>when\u003C\u002Fem> relative to the DOM update — \u003Ccode>&#39;pre&#39;\u003C\u002Fcode> (default) can run before a DOM change, \u003Ccode>&#39;post&#39;\u003C\u002Fcode> waits for it, \u003Ccode>&#39;sync&#39;\u003C\u002Fcode> skips batching entirely.","\u003Ccode>onWatcherCleanup\u003C\u002Fcode> (Vue 3.5+) — or the callback&#39;s third \u003Ccode>onCleanup\u003C\u002Fcode> argument, the only option on earlier versions — fires right before the next run, which is exactly what stops a slow, stale async response from overwriting a fresher one.","A watcher created synchronously in \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode> stops itself on unmount; one created outside a component&#39;s setup needs its returned \u003Ccode>stop()\u003C\u002Fcode> called by hand.",{"id":428,"html":429,"type":138},"b75","\u003Cp>The search box from the top of this article isn&#39;t really a search-box bug — it&#39;s what happens any time a watcher&#39;s \u003Cem>previous\u003C\u002Fem> run outlives its \u003Cem>next\u003C\u002Fem> run and nobody told it to stop. Once cleanup is a habit rather than an afterthought, that entire category of bug stops happening. What&#39;s the last race condition you tracked down that turned out to be a missing cleanup? Drop it in the comments.\u003C\u002Fp>",{"id":431,"html":432,"type":138},"b76","\u003C!-- playground:start -->",{"id":434,"html":435,"text":435,"type":146,"level":31},"b77","🎮 Try it yourself",{"id":437,"html":438,"type":138},"b78","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-watch-vs-watcheffect\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":440,"html":441,"type":138},"b79","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":443,"html":444,"type":138},"b80","\u003C!-- playground:end -->",{"id":446,"html":447,"type":138},"b81","\u003C!-- quiz:start -->",{"id":449,"html":450,"text":450,"type":146,"level":31},"b82","🧠 Test yourself",{"id":452,"html":453,"type":138},"b83","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-watch-vs-watcheffect\u002Fquiz\">Take the 9-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":455,"html":456,"type":138},"b84","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":458,"html":459,"type":138},"b85","\u003C!-- quiz:end -->",{"id":461,"html":462,"type":138},"b86","\u003C!-- related:start -->",{"id":464,"html":465,"text":465,"type":146,"level":31},"b87","📚 Read next",{"id":467,"type":152,"items":468,"ordered":18},"b88",[469,470,471],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-composables-shared-state-trap\">Vue Composables: The Shared State Trap (+ Cheat Sheet)\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-nexttick-batching-dom-updates\">Vue nextTick Explained: How DOM Updates Are Batched\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-reactivity-ref-vs-reactive\">Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)\u003C\u002Fa>",{"id":473,"html":474,"type":138},"b89","\u003C!-- related:end -->",{"id":476,"type":477},"b90","divider",{"id":479,"html":480,"type":138},"b91","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":482,"html":483,"type":138},"b92","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":485,"type":152,"items":486,"ordered":18},"b93",[487,488,489],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","You type \"vue\" into a search box, one letter at a time. Four requests go out — for `v`, `vu`, `vue`, and the deleted-and-retyped `vue` again — and the one for `v` happens to come back last. The screen ends up showing results for a single letter, on top of a `watch()` callback that looks completely correct. Nothing in that callback is wrong. The watcher just never learned that a newer run had already made it obsolete.\n\nThis is the article that fixes that, and along the way answers the question every Vue developer eventually asks: **when do I reach for `watch()`, when do I reach for `watchEffect()`, and why does my callback keep firing at the wrong moment?**\n\n## What you'll learn\n\nBy the end of this article you'll be able to:\n\n- Explain the real difference between `watch()` and `watchEffect()` — not \"one is explicit, one is automatic,\" but *what that difference costs you*\n- Pick the correct `flush` timing (`pre`, `post`, `sync`) for a watcher that needs to read the DOM\n- Use `onWatcherCleanup` to cancel a stale async response before it overwrites a newer one\n- Stop a watcher manually, and know when Vue stops it for you\n- Recognize the specific ways both APIs quietly track (or fail to track) the wrong things\n\n## Prerequisites\n\nYou should already be comfortable with `ref()`, `reactive()`, and `computed()` in the Composition API. If \"why does destructuring a `reactive()` object lose reactivity\" isn't yet second nature, read [Vue Reactivity Explained: ref vs reactive](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij) first — this article assumes that mental model and builds on it.\n\nThis is written against **Vue 3.5.x** (verified September 2026). Version-specific notes (the `once` option, `onWatcherCleanup`) are called out where they apply.\n\n## Table of contents\n\n- [The problem: a watcher with no memory](#the-problem-a-watcher-with-no-memory)\n- [The mental model: pull vs. push tracking](#the-mental-model-pull-vs-push-tracking)\n- [Stage 1: the smallest watch()](#stage-1-the-smallest-watch)\n- [Stage 2: the same job with watchEffect()](#stage-2-the-same-job-with-watcheffect)\n- [Stage 3: multiple sources, and watching inside an object](#stage-3-multiple-sources-and-watching-inside-an-object)\n- [Stage 4: immediate, deep, and once](#stage-4-immediate-deep-and-once)\n- [Stage 5: flush timing — pre, post, sync](#stage-5-flush-timing--pre-post-sync)\n- [Stage 6: cleanup and the race condition, fixed](#stage-6-cleanup-and-the-race-condition-fixed)\n- [Stopping a watcher](#stopping-a-watcher)\n- [Edge cases and gotchas](#edge-cases-and-gotchas)\n- [Best practices](#best-practices)\n- [FAQ](#faq)\n- [Cheat sheet](#cheat-sheet)\n- [Key takeaways](#key-takeaways)\n\n## The problem: a watcher with no memory\n\nHere's the search box, written the way it's tempting to write it the first time:\n\n```vue\n\u003Cscript setup>\nimport { ref, watch } from \"vue\";\n\nconst query = ref(\"\");\nconst results = ref([]);\n\nwatch(query, async (q) => {\n  if (!q) {\n    results.value = [];\n    return;\n  }\n  const res = await fetch(`\u002Fapi\u002Fsearch?q=${encodeURIComponent(q)}`);\n  results.value = await res.json();\n});\n\u003C\u002Fscript>\n```\n\nEvery keystroke fires a new request, and requests do not resolve in the order they were sent. If the request for `v` is slow and the request for `vue` is fast, `vue`'s results land first and `v`'s results land *after*, silently overwriting them. The callback did exactly what it was written to do; it just never asked \"am I still the most recent run?\"\n\nThat question is exactly what a watcher's cleanup mechanism answers. Getting there first requires being precise about what `watch()` and `watchEffect()` actually are.\n\n## The mental model: pull vs. push tracking\n\n**The mental model:** `watch()` is *told* what to watch. `watchEffect()` *discovers* what to watch by running once and recording every reactive value it reads. Everything else — old\u002Fnew values, laziness, immediacy — falls out of that one distinction.\n\n- **`watch(source, callback)`** takes an explicit source (a ref, a getter, a `reactive()` object, or an array of them) and a callback. It does **not** run on creation by default. It runs only when the *named* source changes, and it hands the callback both the new and the old value.\n- **`watchEffect(fn)`** takes a single function with no separate source. It runs **immediately**, once, synchronously (well — see flush timing below) to figure out what it read, and re-runs itself in full whenever any of those dependencies change. There is no \"old value\" concept, because there's no fixed source to diff against — the whole function reruns.\n\nPut differently: `watch` is a subscription to a specific address. `watchEffect` is a subscription to *whatever addresses this function happens to visit today* — and it can visit different addresses on different runs, exactly like a `computed()` getter does. The dependency-tracking mechanism (a run records every reactive value it reads) is shared across `computed`, `watch`'s getter-form sources, and `watchEffect` alike.\n\n## Stage 1: the smallest watch()\n\n```ts\nimport { ref, watch } from \"vue\";\n\nconst count = ref(0);\n\nwatch(count, (newVal, oldVal) => {\n  console.log(`count went from ${oldVal} to ${newVal}`);\n});\n\ncount.value++; \u002F\u002F logs: \"count went from 0 to 1\"\n```\n\n**Key concept:** the callback did not run when `watch` was declared — only after `count` changed. That's the \"lazy by default\" behavior, and it's the first thing that trips people up coming from `watchEffect`.\n\n## Stage 2: the same job with watchEffect()\n\n```ts\nimport { ref, watchEffect } from \"vue\";\n\nconst count = ref(0);\n\nwatchEffect(() => {\n  console.log(`count is now ${count.value}`);\n});\n\u002F\u002F logs immediately: \"count is now 0\"\n\ncount.value++; \u002F\u002F logs: \"count is now 1\"\n```\n\n**Key concept:** `watchEffect` ran once immediately, on creation, before anything changed — because it has to run once just to discover that `count` is a dependency. `watch` never needed to run to know its source; you named it.\n\nIf the job is \"sync this state to something external, right now and on every future change\" — a document title, a `localStorage` key, an analytics call — `watchEffect` is usually the shorter answer. Need the *old* value, or need to stay silent until one specific thing changes? Reach for `watch`.\n\n## Stage 3: multiple sources, and watching inside an object\n\n`watch` accepts an array of sources, and hands the callback matching arrays of new\u002Fold values:\n\n```ts\nwatch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {\n  console.log(`${oldFirst} ${oldLast} -> ${newFirst} ${newLast}`);\n});\n```\n\nWatching a single property of a `reactive()` object requires a **getter function**, not the bare property — because `reactive().someProp` is just a plain value the instant you read it out of the object, with no reference back to the proxy:\n\n```ts\nconst state = reactive({ count: 0, name: \"vue\" });\n\n\u002F\u002F Wrong: this is a snapshot number, not a reactive source.\n\u002F\u002F watch(state.count, cb) never fires.\n\n\u002F\u002F Correct: a getter re-reads the proxy every time Vue checks it.\nwatch(\n  () => state.count,\n  (newCount, oldCount) => console.log(newCount, oldCount)\n);\n```\n\nThis is the same \"reactivity is a subscription made at read time\" rule from the `ref` vs `reactive` article — `watch` needs something it can re-read, and a getter is how you hand it that.\n\n## Stage 4: immediate, deep, and once\n\nThree options on `watch` change when and how often it fires (`watchEffect` accepts only `flush`, covered next, plus the dev-only `onTrack`\u002F`onTrigger` — it runs on creation and tracks everything it reads, so `immediate`, `deep` and `once` don't apply):\n\n```ts\nwatch(source, callback, {\n  immediate: true, \u002F\u002F also run once, right away, with oldValue undefined\n  deep: true,      \u002F\u002F walk the whole object\u002Farray, not just the top-level reference\n  once: true,      \u002F\u002F (Vue 3.4+) auto-stop after the callback fires the first time\n});\n```\n\n`deep: true` is the one to use sparingly. Watching a getter that returns an object (`() => state.someObject`) or a `ref` holding one only fires when the whole object is replaced (a `reactive()` object passed directly is already implicitly deep); `deep` walks every nested property so a change three levels down also fires — at the cost of Vue actually traversing the structure to set that tracking up. On a large or deeply nested object, that traversal is real work.\n\n## Stage 5: flush timing — pre, post, sync\n\nBoth `watch` and `watchEffect` accept `flush`, and it answers a different question than the options above: *relative to a component's own re-render, when does this callback run?*\n\n| `flush` value | When it runs | Default? |\n| --- | --- | --- |\n| `'pre'` | Before the component re-renders | Yes, for both APIs |\n| `'post'` | After the component has re-rendered and the DOM is updated | No |\n| `'sync'` | Synchronously, the instant the dependency is written | No |\n\nThis connects directly to the batching behavior from [Vue nextTick Explained](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-nexttick-explained-how-dom-updates-are-batched-3gnp): a `pre`-flush watcher (the default) can run *before* the DOM reflects the change that triggered it. If your callback needs to measure or read an element that just changed size or content, `flush: 'pre'` will read stale layout:\n\n```ts\nwatch(\n  items,\n  () => {\n    \u002F\u002F With the default 'pre' flush, the DOM here may not yet\n    \u002F\u002F reflect the new `items` — this measurement can be one step behind.\n    console.log(listRef.value.scrollHeight);\n  },\n  { flush: \"post\" } \u002F\u002F wait for the DOM update first\n);\n```\n\n`flush: 'sync'` skips Vue's batching queue entirely and runs the instant the dependency is written — useful for invalidating an external cache the moment something changes, but dangerous on sources mutated many times synchronously (e.g. an array filled in a loop), since it fires on every single mutation with no batching.\n\n## Stage 6: cleanup and the race condition, fixed\n\nBack to the search box. The fix is to give each run a way to say \"abort me if a newer run has already started\" — and both `watch` and `watchEffect` give you exactly that, as `onWatcherCleanup()` (3.5+), which must be called **synchronously** during the callback (before any `await`):\n\n```vue\n\u003Cscript setup>\nimport { ref, watch, onWatcherCleanup } from \"vue\"; \u002F\u002F onWatcherCleanup: Vue 3.5+\n\nconst query = ref(\"\");\nconst results = ref([]);\n\nwatch(query, async (q) => {\n  if (!q) {\n    results.value = [];\n    return;\n  }\n\n  const controller = new AbortController();\n  onWatcherCleanup(() => controller.abort());\n\n  try {\n    const res = await fetch(`\u002Fapi\u002Fsearch?q=${encodeURIComponent(q)}`, {\n      signal: controller.signal,\n    });\n    results.value = await res.json();\n  } catch (e) {\n    if (e.name !== \"AbortError\") throw e; \u002F\u002F an aborted run is expected, not an error\n  }\n});\n\u003C\u002Fscript>\n```\n\n**Key concept:** `onWatcherCleanup` registers a function that Vue calls *right before the next run of this same watcher* — and also when the component unmounts. Type `vue`, and the moment the `e` keystroke starts a new run, the previous run's cleanup fires first, aborting its in-flight fetch. An aborted request rejects with an `AbortError` instead of resolving, so it can never reach `results.value` — and the `try`\u002F`catch` swallows just that expected rejection, which would otherwise surface through Vue's error handler on every superseded keystroke. Order of arrival stops mattering, because only the current, un-aborted request is allowed to write.\n\nThe same mechanism is also passed as the callback's third parameter (the only option before 3.5, and still supported):\n\n```ts\nwatch(query, async (q, oldQ, onCleanup) => {\n  const controller = new AbortController();\n  onCleanup(() => controller.abort());\n  \u002F\u002F ...\n});\n```\n\nThe two forms differ here: `onWatcherCleanup` must be called before the first `await` — after it, Vue can't tell which watcher run it belongs to, so dev mode warns and the cleanup is silently dropped. The `onCleanup` argument is bound to its watcher, so it isn't subject to that constraint.\n\n## Stopping a watcher\n\nBoth `watch` and `watchEffect` return a handle — call it (or `.stop()`) to stop; since 3.5 it also has `.pause()` \u002F `.resume()`:\n\n```ts\nconst stop = watchEffect(() => {\n  console.log(count.value);\n});\n\nstop(); \u002F\u002F this effect will never run again\n```\n\nYou rarely need to call this yourself inside a component: a watcher created **synchronously** during `setup()` or `\u003Cscript setup>` is automatically stopped when the component unmounts; one created later from an async callback (a `setTimeout`, a `.then`) inside that same `\u003Cscript setup>` is not. You need the manual `stop()` mostly for watchers created outside a component's setup — inside a composable that runs after mount, in a store, or in plain JS — where Vue has no unmount to tie it to.\n\n## Edge cases and gotchas\n\n- **`await` breaks dependency discovery.** `watchEffect` only tracks reads that happen synchronously before the first `await`. Anything read after an `await` inside the function is invisible to it and will not trigger a re-run if it changes.\n- **A getter that reads nothing reactive never fires.** `watch(() => someObj.plainProperty, cb)` looks correct but never runs if `plainProperty` isn't itself reactive — there's nothing for Vue to subscribe to.\n- **`watchEffect` can over-track.** Because it tracks everything the function body reads, a large conditional inside it can pick up dependencies you didn't intend, causing extra re-runs. `watch` with an explicit source avoids this by construction.\n- **`.push`\u002F`.splice` fire a non-deep watcher only when the source is the `reactive()` array itself** (implicitly deep). `watch(itemsRef, cb)` on a `ref([])`, or a getter like `() => state.items`, tracks only the array reference — in-place mutations need `deep: true`, or watch `() => items.value.length`.\n- **`immediate: true` hands `undefined` as `oldValue`** on that first synthetic run (an empty array `[]` when watching an array of sources) — always guard against it if the callback compares old and new.\n\n## Best practices\n\n- **Reach for `watchEffect`** when the job is \"keep this side effect in sync with whatever it reads\" and you don't need the old value — document titles, `localStorage`, imperative DOM syncing.\n- **Reach for `watch`** when you need the previous value, need the callback to stay silent until one *specific* thing changes (not everything the function happens to read), or want lazy behavior by default.\n- **Default to `flush: 'pre'`** (the default) unless the callback reads the DOM — then use `'post'`. Reserve `'sync'` for cache-invalidation-style work that must happen before Vue's own batching, and never on a source that's mutated many times in one tick, like an array.\n- **Always pair an async watcher with cleanup** the moment more than one run can be in flight at once — a search box, a resource fetched by an ID prop, anything driven by fast user input.\n- **Avoid `deep: true` on large objects** if you can watch a specific derived getter instead; the traversal cost is real.\n\n## FAQ\n\n### Does watchEffect run on component mount?\n\nIt runs when it's created — during `setup()`, before mount — synchronously with the default `flush: 'pre'`; with `flush: 'post'` (`watchPostEffect`) even the first run waits until after render. That first run is also how it discovers its dependencies.\n\n### What's the difference between watch's deep option and watchEffect?\n\nThey're unrelated axes. `deep` controls how far into a *reactive object* `watch` traverses to detect a change. `watchEffect` always tracks only what it actually reads during a run, deep or shallow, because it has no fixed source to traverse — it just doesn't re-read nested properties unless your function body does.\n\n### Why isn't my watch callback firing when I mutate a reactive object property?\n\nThe most common cause is watching a plain snapshot instead of a getter — see Stage 3. The second most common cause is watching a `ref` holding an object and mutating a nested property without `deep: true` (the object is reactive, but `watch(ref)` only tracks `.value`); the ref's own `.value` reference hasn't changed, so a shallow watch sees nothing.\n\n### How do I stop a watcher manually?\n\nCall the function `watch`\u002F`watchEffect` returns: `const stop = watch(...); stop();`. For a watcher created synchronously inside `\u003Cscript setup>`, you rarely need to — unmount does it for you.\n\n### Is watchEffect the same as a computed?\n\nNo. `computed()` returns a cached *value* meant to be read elsewhere and must stay pure (no side effects). `watchEffect` runs a *side effect* and returns nothing meant to be read — it's for doing something, not deriving something.\n\n### Does watch support async callbacks?\n\nYes — `async (newVal, oldVal) => { ... }` works exactly as shown above. Just remember that `oldVal` and `newVal` are captured before the `await`, and that any `onWatcherCleanup()` call must happen before the first `await`.\n\n## Cheat sheet\n\n```ts\nimport { ref, reactive, watch, watchEffect, onWatcherCleanup } from \"vue\";\n\n\u002F\u002F watch(): explicit source, lazy by default, gives old + new value\nwatch(source, (newVal, oldVal) => { \u002F* ... *\u002F });\nwatch([a, b], ([newA, newB], [oldA, oldB]) => { \u002F* ... *\u002F });\nwatch(() => reactiveObj.prop, (newVal, oldVal) => { \u002F* ... *\u002F }); \u002F\u002F getter for a nested property\n\n\u002F\u002F watchEffect(): auto-tracked, runs immediately, no old value\nwatchEffect(() => { document.title = title.value; });\n\n\u002F\u002F Options (watch only, except flush which both accept)\nwatch(source, cb, {\n  immediate: true, \u002F\u002F also run once immediately (oldValue is undefined that time)\n  deep: true,      \u002F\u002F traverse nested properties, not just the top reference\n  once: true,      \u002F\u002F Vue 3.4+: auto-stop after first fire\n  flush: \"post\",   \u002F\u002F 'pre' (default) | 'post' (after DOM update) | 'sync' (no batching)\n});\n\n\u002F\u002F Cleanup: cancel a stale async run before the next one starts (Vue 3.5+)\nwatch(source, async (val) => {\n  const controller = new AbortController();\n  onWatcherCleanup(() => controller.abort()); \u002F\u002F before the first await\n  try {\n    await fetch(url, { signal: controller.signal });\n  } catch (e) {\n    if (e.name !== \"AbortError\") throw e;\n  }\n});\n\n\u002F\u002F Stopping\nconst stop = watchEffect(() => { \u002F* ... *\u002F });\nstop(); \u002F\u002F manual — usually unnecessary for watchers created synchronously in \u003Cscript setup>, which auto-stop on unmount\n\u002F\u002F Vue 3.5+: the handle also has stop.pause() \u002F stop.resume()\n```\n\n## Key takeaways\n\n- `watch` is told its source explicitly and is lazy by default; `watchEffect` discovers its sources by running once, immediately, and re-runs the whole function when any of them change.\n- `flush` decides *when* relative to the DOM update — `'pre'` (default) can run before a DOM change, `'post'` waits for it, `'sync'` skips batching entirely.\n- `onWatcherCleanup` (Vue 3.5+) — or the callback's third `onCleanup` argument, the only option on earlier versions — fires right before the next run, which is exactly what stops a slow, stale async response from overwriting a fresher one.\n- A watcher created synchronously in `\u003Cscript setup>` stops itself on unmount; one created outside a component's setup needs its returned `stop()` called by hand.\n\nThe search box from the top of this article isn't really a search-box bug — it's what happens any time a watcher's *previous* run outlives its *next* run and nobody told it to stop. Once cleanup is a habit rather than an afterthought, that entire category of bug stops happening. What's the last race condition you tracked down that turned out to be a missing cleanup? Drop it in the comments.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-watch-vs-watcheffect\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 9-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-watch-vs-watcheffect\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [Vue Composables: The Shared State Trap (+ Cheat Sheet)](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-composables-shared-state-trap)\n- [Vue nextTick Explained: How DOM Updates Are Batched](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-nexttick-batching-dom-updates)\n- [Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-reactivity-ref-vs-reactive)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":109,"canonical":492,"description":110},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-watch-vs-watcheffect","01a0ec73-2e42-76ec-87cd-d31b9166ab40",{"name":495,"part":496,"total":496,"items":497},"Vue Deep Dive",6,[498,503,507,511,515,519],{"slug":499,"title":500,"publishedAt":501,"readingMinutes":502},"vue-weekly-reactivity-ref-vs-reactive","Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)","2026-08-24T13:57:38.080Z",14,{"slug":504,"title":505,"publishedAt":506,"readingMinutes":96},"vue-weekly-nexttick-batching-dom-updates","Vue nextTick Explained: How DOM Updates Are Batched","2026-09-14T12:19:00.606Z",{"slug":508,"title":509,"publishedAt":510,"readingMinutes":96},"vue-weekly-composables-shared-state-trap","Vue Composables: The Shared State Trap (+ Cheat Sheet)","2026-09-22T19:37:11.460Z",{"slug":512,"title":513,"publishedAt":514,"readingMinutes":100},"vue-weekly-computed-caching-invalidation","Vue computed(): What It Caches and When It Reruns","2026-09-30T12:10:53.362Z",{"slug":516,"title":517,"publishedAt":518,"readingMinutes":96},"vue-weekly-definemodel-v-model-contract","Vue defineModel: The v-model Contract, Explained","2026-10-05T06:11:02.623Z",{"slug":46,"title":109,"publishedAt":113,"readingMinutes":112},{"id":521,"locked":18},"01a0ec73-2eec-754e-8b83-736a2ce917c3",[523],{"id":45,"slug":46,"title":48,"_count":524},{"questions":51},[526],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":528,"questionCount":51},{"questions":51}]