[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-vue-weekly-composables-shared-state-trap":44,"search-suggestions":60,"quiz-article-vue-weekly-composables-shared-state-trap":106},[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},"01a066ea-30d4-77af-87a7-5118d8a617a1","vue-weekly-composables-shared-state-trap","PRACTICE_QUIZ","Vue Composables: The Shared State Trap","Seven questions on where a composable's state actually lives, why two components can end up sharing it by accident, and how to clean it up correctly.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},7,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,100,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",107,{"slug":66,"name":67,"articles":68},"javascript","Javascript",89,{"slug":70,"name":71,"articles":72},"frontend","Frontend",72,{"slug":74,"name":75,"articles":76},"css","Css",36,{"slug":78,"name":79,"articles":80},"tutorial","Tutorial",35,{"slug":82,"name":83,"articles":84},"typescript","Typescript",15,{"slug":86,"name":87,"articles":88},"react","React",13,{"slug":90,"name":91,"articles":92},"performance","Performance",12,{"slug":94,"name":95,"articles":96},"browser","Browser",9,{"slug":98,"name":99,"articles":96},"node","Node",{"slug":101,"name":102,"articles":51},"html","Html",{"slug":104,"name":105,"articles":51},"accessibility","Accessibility",{"id":107,"slug":46,"title":108,"subtitle":52,"excerpt":109,"coverUrl":110,"locale":13,"readingMinutes":88,"publishedAt":111,"viewCount":112,"likeCount":19,"commentCount":19,"author":113,"vertical":118,"topic":119,"tags":122,"_count":127,"playground":129,"body":131,"bodyMd":464,"seo":465,"translationGroupId":467,"series":468,"podcastUrl":52,"verticalId":5,"thread":481,"assessments":483,"translations":486,"quiz":488},"01a066ea-3098-75c9-b7e5-1d5426f773eb","Vue Composables: The Shared State Trap (+ Cheat Sheet)","Why two Vue components using the same composable can end up sharing state by accident, the mental model that explains it, and how to fix or embrace it.","\u002Fmedia\u002Fcovers\u002Fvue-weekly-composables-shared-state-trap.png","2026-09-22T19:37:11.460Z",33,{"id":114,"name":115,"username":116,"avatarUrl":52,"headline":117},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":120,"name":121},"vue","Vue",[123,124,125,126],{"slug":120,"name":121,"color":52},{"slug":66,"name":67,"color":52},{"slug":78,"name":79,"color":52},{"slug":62,"name":63,"color":52},{"assessments":128},1,{"slug":46,"title":130},"Vue composables: the shared state trap — interactive playground",{"blocks":132,"version":128},[133,137,140,145,148,158,161,164,167,170,183,186,189,195,198,202,206,209,212,215,218,223,226,229,232,235,239,242,245,248,251,255,258,261,264,267,271,274,277,281,284,287,290,293,297,300,303,310,313,321,324,328,331,335,338,341,344,348,351,354,357,360,363,366,369,372,375,415,418,425,428,431,434,437,440,443,446,449,452,455,458],{"id":134,"html":135,"type":136},"b1","\u003Cp>You build a \u003Ccode>useCounter()\u003C\u002Fcode> composable, drop \u003Ccode>&lt;Counter \u002F&gt;\u003C\u002Fcode> on the page twice, and click the first button. Both counters go up.\u003C\u002Fp>","paragraph",{"id":138,"html":139,"type":136},"b2","\u003Cp>You didn&#39;t copy-paste a bug. You wrote twelve lines of completely ordinary-looking Composition API code, and Vue executed every one of them correctly. The surprise isn&#39;t a Vue bug — it&#39;s a gap in your mental model of what a composable actually \u003Cem>is\u003C\u002Fem>, and that gap is exactly where a much more expensive version of this same mistake lives: on a server, where &quot;the other counter&quot; is a different user&#39;s browser tab.\u003C\u002Fp>",{"id":141,"html":142,"text":143,"type":144,"level":31},"b3","What you&#39;ll learn","What you'll learn","heading",{"id":146,"html":147,"type":136},"b4","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":149,"type":150,"items":151,"ordered":18},"b5","list",[152,153,154,155,156,157],"Explain why two calls to the same composable sometimes share state and sometimes don&#39;t","Predict it from reading the composable&#39;s source, without running the app","Deliberately design shared (singleton) state when you actually want it","Clean up a composable&#39;s side effects so they don&#39;t outlive the component that created them","Recognize why this exact bug is more dangerous in server-rendered Vue than in the browser","Keep the cheat sheet at the end open while you build your next composable",{"id":159,"html":160,"text":160,"type":144,"level":31},"b6","Who this is for",{"id":162,"html":163,"type":136},"b7","\u003Cp>You&#39;ve written at least one composable — a \u003Ccode>use*\u003C\u002Fcode> function that returns some \u003Ccode>ref\u003C\u002Fcode>s and functions from \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode>. You don&#39;t need to have hit this bug yet; you will recognize it the moment you see it. If \u003Ccode>ref\u003C\u002Fcode> vs \u003Ccode>reactive\u003C\u002Fcode> is still fuzzy, \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij\">Vue Reactivity Explained: ref vs reactive\u003C\u002Fa> is a good five minutes first — this article builds on it but doesn&#39;t require it.\u003C\u002Fp>",{"id":165,"html":166,"type":136},"b8","\u003Cp>This article is written against \u003Cstrong>Vue 3.5.42\u003C\u002Fstrong>, the current stable release (verified via the npm registry, September 2026). Vue 3.6 is still in release candidate; nothing here changes under it — composables are a code-organization pattern, not a runtime feature that Vapor Mode alters.\u003C\u002Fp>",{"id":168,"html":169,"text":169,"type":144,"level":31},"b9","Table of contents",{"id":171,"type":150,"items":172,"ordered":18},"b10",[173,174,175,176,177,178,179,180,181,182],"\u003Ca href=\"#the-problem-two-counters-one-number\">The problem: two counters, one number\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-a-composable-is-just-a-function\">The mental model: a composable is just a function\u003C\u002Fa>","\u003Ca href=\"#stage-1-state-that-lives-inside-the-function\">Stage 1: state that lives inside the function\u003C\u002Fa>","\u003Ca href=\"#stage-2-composables-with-side-effects-need-cleanup\">Stage 2: composables with side effects need cleanup\u003C\u002Fa>","\u003Ca href=\"#stage-3-composables-that-call-other-composables\">Stage 3: composables that call other composables\u003C\u002Fa>","\u003Ca href=\"#stage-4-when-shared-state-is-what-you-actually-want\">Stage 4: when shared state is what you actually want\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>",{"id":184,"html":185,"text":185,"type":144,"level":31},"b11","The problem: two counters, one number",{"id":187,"html":188,"type":136},"b12","\u003Cp>Here&#39;s the composable, written the way it looks in a hundred tutorials:\u003C\u002Fp>",{"id":190,"code":191,"type":192,"language":193,"highlight":194},"b13","\u002F\u002F useCounter.js\nimport { ref } from 'vue'\n\nconst count = ref(0) \u002F\u002F looks like \"the counter's state\" — but whose?\n\nexport function useCounter() {\n  function increment() {\n    count.value++\n  }\n  return { count, increment }\n}","code","js",[],{"id":196,"html":197,"type":136},"b14","\u003Cp>And here&#39;s a component that uses it, dropped onto a page twice:\u003C\u002Fp>",{"id":199,"code":200,"type":192,"language":120,"highlight":201},"b15","\u003C!-- Counter.vue -->\n\u003Cscript setup>\nimport { useCounter } from '.\u002FuseCounter'\nconst { count, increment } = useCounter()\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cbutton @click=\"increment\">Count: {{ count }}\u003C\u002Fbutton>\n\u003C\u002Ftemplate>",[],{"id":203,"code":204,"type":192,"language":120,"highlight":205},"b16","\u003C!-- Page.vue -->\n\u003Ctemplate>\n  \u003CCounter \u002F>\n  \u003CCounter \u002F>\n\u003C\u002Ftemplate>",[],{"id":207,"html":208,"type":136},"b17","\u003Cp>Click the first button five times. Both buttons read \u003Ccode>Count: 5\u003C\u002Fcode>. Nothing crashed, nothing warned you — \u003Ccode>useCounter()\u003C\u002Fcode> did precisely what its source says: return the one \u003Ccode>count\u003C\u002Fcode> that exists.\u003C\u002Fp>",{"id":210,"html":211,"type":136},"b18","\u003Cp>The bug is not in \u003Ccode>&lt;Counter \u002F&gt;\u003C\u002Fcode>. It&#39;s a single misplaced line in \u003Ccode>useCounter.js\u003C\u002Fcode> — and to see which line, you need the rule that explains both the broken version and the correct one.\u003C\u002Fp>",{"id":213,"html":214,"text":214,"type":144,"level":31},"b19","The mental model: a composable is just a function",{"id":216,"html":217,"type":136},"b20","\u003Cp>\u003Cstrong>A composable has no special relationship to &quot;a component&#39;s state.&quot; It&#39;s a plain JavaScript function, and JavaScript&#39;s own scoping rules decide everything.\u003C\u002Fstrong>\u003C\u002Fp>",{"id":219,"type":150,"items":220,"ordered":18},"b21",[221,222],"State declared \u003Cstrong>inside\u003C\u002Fstrong> the function body is created fresh every time the function \u003Cem>runs\u003C\u002Fem>. Since \u003Ccode>&lt;Counter \u002F&gt;\u003C\u002Fcode> calls \u003Ccode>useCounter()\u003C\u002Fcode> once per component instance, each instance gets its own \u003Ccode>ref\u003C\u002Fcode> — private state.","State declared \u003Cstrong>outside\u003C\u002Fstrong> the function — at module scope, in the file that defines it — is created exactly \u003Cstrong>once\u003C\u002Fstrong>, the first time anything imports the module. Every subsequent call to \u003Ccode>useCounter()\u003C\u002Fcode> returns a reference to that same \u003Ccode>ref\u003C\u002Fcode>. That&#39;s not a bug in the composable pattern; it&#39;s how ES modules always work, for the same reason a module-level \u003Ccode>let cache = new Map()\u003C\u002Fcode> is shared by every caller too.",{"id":224,"html":225,"type":136},"b22","\u003Cp>Vue&#39;s Composition API rules (\u003Ccode>ref\u003C\u002Fcode>, \u003Ccode>reactive\u003C\u002Fcode>, \u003Ccode>onMounted\u003C\u002Fcode>, \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode>, …) never asked \u003Cem>where\u003C\u002Fem> you put your \u003Ccode>const\u003C\u002Fcode>. You decided that, with ordinary variable scope, before Vue ever entered the picture. The composable naming convention (\u003Ccode>useXxx\u003C\u002Fcode>) signals \u003Cem>what it does\u003C\u002Fem>, not \u003Cem>how many copies of it exist\u003C\u002Fem> — that second question is answered by reading exactly one thing: is this \u003Ccode>ref()\u003C\u002Fcode> call inside the exported function, or outside it?\u003C\u002Fp>",{"id":227,"html":228,"type":136},"b23","\u003Cp>That&#39;s the whole model. Everything below is a consequence of it.\u003C\u002Fp>",{"id":230,"html":231,"text":231,"type":144,"level":31},"b24","Stage 1: state that lives inside the function",{"id":233,"html":234,"type":136},"b25","\u003Cp>Move \u003Ccode>count\u003C\u002Fcode> inside \u003Ccode>useCounter\u003C\u002Fcode>, and each call becomes its own closure over its own \u003Ccode>ref\u003C\u002Fcode>:\u003C\u002Fp>",{"id":236,"code":237,"type":192,"language":193,"highlight":238},"b26","\u002F\u002F useCounter.js — fixed\nimport { ref } from 'vue'\n\nexport function useCounter(start = 0) {\n  const count = ref(start) \u002F\u002F created fresh, once per CALL, not once per FILE\n  function increment() {\n    count.value++\n  }\n  return { count, increment }\n}",[],{"id":240,"html":241,"type":136},"b27","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> nothing about the export changed — same function name, same return shape. Only the \u003Cem>scope\u003C\u002Fem> of \u003Ccode>count\u003C\u002Fcode> moved from module-level to function-level. Now two \u003Ccode>&lt;Counter \u002F&gt;\u003C\u002Fcode> instances each run \u003Ccode>useCounter()\u003C\u002Fcode> independently, each closing over its own \u003Ccode>count\u003C\u002Fcode>, and clicking one leaves the other alone.\u003C\u002Fp>",{"id":243,"html":244,"type":136},"b28","\u003Cp>This is the default you want almost every time. If you&#39;re writing a composable and you haven&#39;t deliberately decided otherwise, put the state inside the function.\u003C\u002Fp>",{"id":246,"html":247,"text":247,"type":144,"level":31},"b29","Stage 2: composables with side effects need cleanup",{"id":249,"html":250,"type":136},"b30","\u003Cp>Composables aren&#39;t only about state — they often start something that needs to be stopped. A classic:\u003C\u002Fp>",{"id":252,"code":253,"type":192,"language":193,"highlight":254},"b31","\u002F\u002F useMousePosition.js\nimport { ref, onMounted, onUnmounted } from 'vue'\n\nexport function useMousePosition() {\n  const x = ref(0)\n  const y = ref(0)\n\n  function update(e) {\n    x.value = e.clientX\n    y.value = e.clientY\n  }\n\n  onMounted(() => window.addEventListener('mousemove', update))\n  onUnmounted(() => window.removeEventListener('mousemove', update)) \u002F\u002F \u003C- easy to forget\n\n  return { x, y }\n}",[],{"id":256,"html":257,"type":136},"b32","\u003Cp>Drop the \u003Ccode>onUnmounted\u003C\u002Fcode> line, and every component that ever used this composable leaves a \u003Ccode>mousemove\u003C\u002Fcode> listener attached to \u003Ccode>window\u003C\u002Fcode> forever, each one still running its \u003Ccode>update\u003C\u002Fcode> closure against a \u003Ccode>ref\u003C\u002Fcode> nobody reads anymore. It&#39;s not a crash — it&#39;s a slow, silent leak that only shows up as &quot;the page gets janky after navigating around for a while.&quot;\u003C\u002Fp>",{"id":259,"html":260,"type":136},"b33","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> a composable&#39;s lifecycle hooks (\u003Ccode>onMounted\u003C\u002Fcode>, \u003Ccode>onUnmounted\u003C\u002Fcode>, \u003Ccode>onScopeDispose\u003C\u002Fcode>) tie the side effect to whichever component (or, as Stage 3 shows, effect scope) called the composable. If you start something, you are responsible for stopping it in the matching hook — Vue does not infer the teardown from the setup.\u003C\u002Fp>",{"id":262,"html":263,"text":263,"type":144,"level":31},"b34","Stage 3: composables that call other composables",{"id":265,"html":266,"type":136},"b35","\u003Cp>Composables compose — \u003Ccode>useMousePosition\u003C\u002Fcode> might be built from a lower-level \u003Ccode>useEventListener\u003C\u002Fcode>:\u003C\u002Fp>",{"id":268,"code":269,"type":192,"language":193,"highlight":270},"b36","\u002F\u002F useEventListener.js\nimport { onMounted, onUnmounted } from 'vue'\n\nexport function useEventListener(target, event, handler) {\n  onMounted(() => target.addEventListener(event, handler))\n  onUnmounted(() => target.removeEventListener(event, handler))\n}",[],{"id":272,"html":273,"type":136},"b37","\u003Cp>This works fine called from \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode>, because \u003Ccode>onMounted\u003C\u002Fcode>\u002F\u003Ccode>onUnmounted\u003C\u002Fcode> register against the \u003Cem>currently running\u003C\u002Fem> component instance — and during \u003Ccode>setup()\u003C\u002Fcode>, there is one. It breaks the moment you call it from somewhere that isn&#39;t a component, like a Pinia store&#39;s \u003Ccode>setup\u003C\u002Fcode> function running at app-init time, or a plain test file: there&#39;s no &quot;current instance&quot; for the hook to attach to, Vue logs a dev warning, and the callback simply never fires.\u003C\u002Fp>",{"id":275,"html":276,"type":136},"b38","\u003Cp>The general-purpose fix is \u003Ccode>onScopeDispose\u003C\u002Fcode>, which ties into Vue&#39;s \u003Ca href=\"https:\u002F\u002Fvuejs.org\u002Fapi\u002Freactivity-advanced.html#effectscope\">effect scope\u003C\u002Fa> instead of specifically a component instance — and a component&#39;s own setup function is itself one:\u003C\u002Fp>",{"id":278,"code":279,"type":192,"language":193,"highlight":280},"b39","import { onScopeDispose } from 'vue'\n\nexport function useEventListener(target, event, handler) {\n  target.addEventListener(event, handler)\n  onScopeDispose(() => target.removeEventListener(event, handler))\n}",[],{"id":282,"html":283,"type":136},"b40","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> \u003Ccode>onScopeDispose\u003C\u002Fcode> fires whenever the enclosing effect scope tears down — a component unmounting, or an \u003Ccode>effectScope().stop()\u003C\u002Fcode> call you make yourself outside any component. It&#39;s the one cleanup hook that works the same whether the composable is called from a component or from plain code, which is exactly what a composable meant to be reused outside components needs.\u003C\u002Fp>",{"id":285,"html":286,"type":136},"b41","\u003Cp>If you skipped \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij\">Vue Reactivity Explained\u003C\u002Fa>, the same destructuring rule from that article applies here too: if \u003Ccode>useEventListener\u003C\u002Fcode> returned a \u003Ccode>reactive()\u003C\u002Fcode> object instead of individual \u003Ccode>ref\u003C\u002Fcode>s, destructuring it in the caller would silently drop reactivity. Composables should almost always return an object of \u003Ccode>ref\u003C\u002Fcode>s (or use \u003Ccode>toRefs\u003C\u002Fcode> on a \u003Ccode>reactive\u003C\u002Fcode> one) so a caller can safely write \u003Ccode>const { x, y } = useMousePosition()\u003C\u002Fcode>.\u003C\u002Fp>",{"id":288,"html":289,"text":289,"type":144,"level":31},"b42","Stage 4: when shared state is what you actually want",{"id":291,"html":292,"type":136},"b43","\u003Cp>Sometimes module-scoped state is the correct design, not a bug — a logged-in user, a theme preference, a WebSocket connection every component should see the same instance of. The fix isn&#39;t &quot;always put state inside the function&quot;; it&#39;s &quot;put it outside the function on purpose, and say so&quot;:\u003C\u002Fp>",{"id":294,"code":295,"type":192,"language":193,"highlight":296},"b44","\u002F\u002F useAuthUser.js — a deliberate singleton, not an accident\nimport { ref, readonly } from 'vue'\n\n\u002F\u002F Module scope, intentionally: every caller across the whole app\n\u002F\u002F shares this exact user. This is a global store, not per-call state.\nconst _user = ref(null)\n\nexport function useAuthUser() {\n  function login(user) {\n    _user.value = user\n  }\n  function logout() {\n    _user.value = null\n  }\n  \u002F\u002F readonly() stops callers from doing `user.value = ...` directly —\n  \u002F\u002F every write goes through login()\u002Flogout(), which is the whole point\n  \u002F\u002F of centralizing this state in the first place.\n  return { user: readonly(_user), login, logout }\n}",[],{"id":298,"html":299,"type":136},"b45","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> the code that makes this a singleton is identical in shape to the bug from the opening example — a \u003Ccode>ref\u003C\u002Fcode> declared outside the function. The difference is entirely in intent and in guarding the write path. A comment explaining \u003Cem>why\u003C\u002Fem> this one is module-scoped is not decoration; it&#39;s the one piece of information a reader can&#39;t get from the code&#39;s shape, because the shape is the same as the accidental version.\u003C\u002Fp>",{"id":301,"html":302,"text":302,"type":144,"level":31},"b46","Edge cases and gotchas",{"id":304,"type":150,"items":305,"ordered":18},"b47",[306,307,308,309],"\u003Cstrong>Server-side rendering makes this bug worse, not just different.\u003C\u002Fstrong> In the browser, a module-scoped \u003Ccode>ref\u003C\u002Fcode> is shared across components in \u003Cem>one user&#39;s\u003C\u002Fem> page — annoying, but contained. On a Vue SSR server (Nuxt or a custom setup), a Node process typically renders many different users&#39; requests using the \u003Cem>same\u003C\u002Fem> loaded module. A \u003Ccode>ref\u003C\u002Fcode> created at module scope is created once when the server starts, not once per request — so \u003Ccode>useAuthUser\u003C\u002Fcode>&#39;s state can leak from the user who requested it first into the response sent to the next user. This is the single most important reason &quot;does this composable&#39;s state live inside or outside the function&quot; is worth getting right on purpose, not by habit.","\u003Cstrong>Testing a composable outside a component throws or warns.\u003C\u002Fstrong> Calling \u003Ccode>onMounted\u003C\u002Fcode>\u002F\u003Ccode>onUnmounted\u003C\u002Fcode> with no active component instance triggers a dev-mode warning and does nothing. Wrap the call in \u003Ccode>effectScope()\u003C\u002Fcode> when unit-testing a composable directly, so lifecycle-style hooks (\u003Ccode>onScopeDispose\u003C\u002Fcode>) have somewhere to attach:\n\u003Ccode>js import { effectScope } from &#39;vue&#39; const scope = effectScope() scope.run(() =&gt; {   const { count, increment } = useCounter()   increment()   expect(count.value).toBe(1) }) scope.stop() \u002F\u002F triggers any onScopeDispose cleanup registered inside \u003C\u002Fcode>","\u003Cstrong>Hot Module Replacement can hide the bug in dev.\u003C\u002Fstrong> Vite&#39;s HMR sometimes re-executes a module and resets its module-scope state, which can make a shared-state leak look fixed after a save — then reappear on a full reload. Don&#39;t trust &quot;it works now&quot; from an HMR session; reload the page.","\u003Cstrong>\u003Ccode>getCurrentInstance()\u003C\u002Fcode> is not the fix.\u003C\u002Fstrong> It&#39;s tempting to reach for it to detect &quot;am I inside a component,&quot; but it&#39;s explicitly documented as an internal API for advanced\u002Flibrary use, not a general escape hatch — prefer designing the composable so it doesn&#39;t need to know.",{"id":311,"html":312,"text":312,"type":144,"level":31},"b48","Best practices",{"id":314,"type":150,"items":315,"ordered":18},"b49",[316,317,318,319,320],"\u003Cstrong>Default to state inside the function.\u003C\u002Fstrong> Only hoist state to module scope when you specifically want one shared instance across the whole app.","\u003Cstrong>Say so when you do.\u003C\u002Fstrong> A one-line comment (&quot;module-scope singleton, shared across the app&quot;) costs nothing and saves the next reader from re-deriving your intent from scope rules.","\u003Cstrong>Return \u003Ccode>ref\u003C\u002Fcode>s, not a raw \u003Ccode>reactive()\u003C\u002Fcode> object\u003C\u002Fstrong>, so callers can destructure without losing reactivity.","\u003Cstrong>Clean up every side effect\u003C\u002Fstrong> a composable starts — a listener, a timer, a subscription — with \u003Ccode>onUnmounted\u003C\u002Fcode> for component-only composables, \u003Ccode>onScopeDispose\u003C\u002Fcode> for anything meant to be reusable outside a component.","\u003Cstrong>For real app-wide state, reach for Pinia\u003C\u002Fstrong> once it grows past one or two values. It gives you the same &quot;one shared store&quot; idea, but with devtools time-travel, SSR-safe store instantiation per request, and a name (\u003Ccode>defineStore\u003C\u002Fcode>) that makes &quot;this is intentionally shared&quot; obvious from the import alone.",{"id":322,"html":323,"text":323,"type":144,"level":31},"b50","FAQ",{"id":325,"html":326,"text":327,"type":144,"level":43},"b51","Why didn&#39;t Vue warn me that my composable&#39;s state was shared?","Why didn't Vue warn me that my composable's state was shared?",{"id":329,"html":330,"type":136},"b52","\u003Cp>Because there&#39;s nothing to warn about — \u003Ccode>const count = ref(0)\u003C\u002Fcode> at module scope is completely valid JavaScript and Vue reactivity, doing exactly what it&#39;s written to do. The &quot;bug&quot; is a mismatch between your intent and the code&#39;s actual scope, which no linter can read your mind about.\u003C\u002Fp>",{"id":332,"html":333,"text":334,"type":144,"level":43},"b53","Does this affect \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> the same way?","Does this affect provide\u002Finject the same way?",{"id":336,"html":337,"type":136},"b54","\u003Cp>No — \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> is scoped to the component tree by design; each provider creates its own value for its own descendants, so two unrelated component trees don&#39;t share it. The module-scope trap is specific to state declared directly in a \u003Ccode>.js\u003C\u002Fcode> file outside any function.\u003C\u002Fp>",{"id":339,"html":340,"text":340,"type":144,"level":43},"b55","Is a composable just a React-style custom hook?",{"id":342,"html":343,"type":136},"b56","\u003Cp>They solve the same problem — extracting stateful logic into a reusable function — but the mechanics differ. A composable&#39;s \u003Ccode>ref\u003C\u002Fcode>s are proxies you can read anywhere without a special calling rule, and there is no &quot;rules of hooks&quot; about call order; a custom hook&#39;s state is closed over by React&#39;s fiber and re-runs the whole function on every render. The shared-state trap in this article has no React equivalent, because React hooks don&#39;t have a module-scope option — \u003Ccode>useState\u003C\u002Fcode> is always per-component.\u003C\u002Fp>",{"id":345,"html":346,"text":347,"type":144,"level":43},"b57","Should every composable clean up after itself, even ones that don&#39;t touch the DOM?","Should every composable clean up after itself, even ones that don't touch the DOM?",{"id":349,"html":350,"type":136},"b58","\u003Cp>If it started anything — a listener, an interval, a subscription to something outside Vue&#39;s reactivity — yes. If it only computed derived values from its inputs (\u003Ccode>computed\u003C\u002Fcode>, plain functions), there&#39;s nothing to clean up; \u003Ccode>computed\u003C\u002Fcode> refs are garbage-collected with everything else that stops referencing them.\u003C\u002Fp>",{"id":352,"html":353,"text":353,"type":144,"level":43},"b59","Can I tell from the outside whether a composable shares state?",{"id":355,"html":356,"type":136},"b60","\u003Cp>Only by reading its source (or its docs). The call site — \u003Ccode>const { count, increment } = useCounter()\u003C\u002Fcode> — looks identical either way. That asymmetry is exactly why Stage 4&#39;s naming\u002Fcomment convention matters: the information isn&#39;t visible where you&#39;d need it most.\u003C\u002Fp>",{"id":358,"html":359,"type":136},"b61","\u003C!-- playground:start -->",{"id":361,"html":362,"text":362,"type":144,"level":31},"b62","🎮 Try it yourself",{"id":364,"html":365,"type":136},"b63","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-composables-shared-state-trap\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":367,"html":368,"type":136},"b64","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":370,"html":371,"type":136},"b65","\u003C!-- playground:end -->",{"id":373,"html":374,"text":374,"type":144,"level":31},"b66","Cheat sheet",{"id":376,"head":377,"rows":381,"type":414},"b67",[378,379,380],"Pattern","Code","Result",[382,386,390,394,398,402,406,410],[383,384,385],"Per-caller state (default)","\u003Ccode>ref()\u003C\u002Fcode> declared \u003Cstrong>inside\u003C\u002Fstrong> the exported function","Each call to \u003Ccode>useX()\u003C\u002Fcode> gets its own private state",[387,388,389],"Shared\u002Fsingleton state","\u003Ccode>ref()\u003C\u002Fcode> declared \u003Cstrong>outside\u003C\u002Fstrong> the function, at module scope","Every call to \u003Ccode>useX()\u003C\u002Fcode> returns the same state — intentional or not",[391,392,393],"Safe destructuring","Return individual \u003Ccode>ref\u003C\u002Fcode>s: \u003Ccode>return { count, increment }\u003C\u002Fcode>","Caller can destructure without losing reactivity",[395,396,397],"Component-only cleanup","\u003Ccode>onUnmounted(() =&gt; …)\u003C\u002Fcode>","Runs when the owning component unmounts",[399,400,401],"Reusable-anywhere cleanup","\u003Ccode>onScopeDispose(() =&gt; …)\u003C\u002Fcode>","Runs when the enclosing effect scope stops — component or manual \u003Ccode>effectScope()\u003C\u002Fcode>",[403,404,405],"Protect a singleton&#39;s writes","\u003Ccode>return { user: readonly(_user), login, logout }\u003C\u002Fcode>","Callers can read but must go through your functions to write",[407,408,409],"Test a composable in isolation","\u003Ccode>effectScope().run(() =&gt; { … })\u003C\u002Fcode>, then \u003Ccode>.stop()\u003C\u002Fcode>","Gives lifecycle-style hooks somewhere to attach outside a component",[411,412,413],"SSR danger sign","A module-scope \u003Ccode>ref\u003C\u002Fcode> holding per-user data (auth, cart, …)","Can leak between requests on a server — make it request-scoped or use Pinia","table",{"id":416,"html":417,"text":417,"type":144,"level":31},"b68","Key takeaways",{"id":419,"type":150,"items":420,"ordered":18},"b69",[421,422,423,424],"A composable is a function first; JavaScript&#39;s own variable scope — not anything Vue-specific — decides whether its state is private per call or one shared singleton.","State inside the function body is the safe default; state outside it is a deliberate design choice that deserves a comment.","Every side effect a composable starts needs a matching cleanup, via \u003Ccode>onUnmounted\u003C\u002Fcode> or the more general \u003Ccode>onScopeDispose\u003C\u002Fcode>.","The same &quot;module scope = shared&quot; rule that causes two counters to double-count in the browser can leak one user&#39;s data into another user&#39;s response on an SSR server — treat it as a correctness issue, not just a UI quirk.",{"id":426,"html":427,"type":136},"b70","\u003C!-- quiz:start -->",{"id":429,"html":430,"text":430,"type":144,"level":31},"b71","🧠 Test yourself",{"id":432,"html":433,"type":136},"b72","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-composables-shared-state-trap\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":435,"html":436,"type":136},"b73","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":438,"html":439,"type":136},"b74","\u003C!-- quiz:end -->",{"id":441,"html":442,"text":442,"type":144,"level":31},"b75","Ending",{"id":444,"html":445,"type":136},"b76","\u003Cp>Those two counters that moved together weren&#39;t broken — they were exactly one \u003Ccode>ref\u003C\u002Fcode> doing exactly what a \u003Ccode>ref\u003C\u002Fcode> declared outside a function always does. Once you can see that scope decision sitting in the source, every &quot;why are these two components sharing state&quot; question stops being a mystery and starts being a one-line answer you can find by reading, not debugging.\u003C\u002Fp>",{"id":447,"html":448,"type":136},"b77","\u003Cp>Where has this bitten you — a shared composable, or the SSR version of it? Tell me in the comments.\u003C\u002Fp>",{"id":450,"type":451},"b78","divider",{"id":453,"html":454,"type":136},"b79","\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":456,"html":457,"type":136},"b80","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":459,"type":150,"items":460,"ordered":18},"b81",[461,462,463],"⭐ \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 build a `useCounter()` composable, drop `\u003CCounter \u002F>` on the page twice, and click the first button. Both counters go up.\n\nYou didn't copy-paste a bug. You wrote twelve lines of completely ordinary-looking Composition API code, and Vue executed every one of them correctly. The surprise isn't a Vue bug — it's a gap in your mental model of what a composable actually *is*, and that gap is exactly where a much more expensive version of this same mistake lives: on a server, where \"the other counter\" is a different user's browser tab.\n\n## What you'll learn\n\nBy the end of this article you'll be able to:\n\n- Explain why two calls to the same composable sometimes share state and sometimes don't\n- Predict it from reading the composable's source, without running the app\n- Deliberately design shared (singleton) state when you actually want it\n- Clean up a composable's side effects so they don't outlive the component that created them\n- Recognize why this exact bug is more dangerous in server-rendered Vue than in the browser\n- Keep the cheat sheet at the end open while you build your next composable\n\n## Who this is for\n\nYou've written at least one composable — a `use*` function that returns some `ref`s and functions from `\u003Cscript setup>`. You don't need to have hit this bug yet; you will recognize it the moment you see it. If `ref` vs `reactive` is still fuzzy, [Vue Reactivity Explained: ref vs reactive](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij) is a good five minutes first — this article builds on it but doesn't require it.\n\nThis article is written against **Vue 3.5.42**, the current stable release (verified via the npm registry, September 2026). Vue 3.6 is still in release candidate; nothing here changes under it — composables are a code-organization pattern, not a runtime feature that Vapor Mode alters.\n\n## Table of contents\n\n- [The problem: two counters, one number](#the-problem-two-counters-one-number)\n- [The mental model: a composable is just a function](#the-mental-model-a-composable-is-just-a-function)\n- [Stage 1: state that lives inside the function](#stage-1-state-that-lives-inside-the-function)\n- [Stage 2: composables with side effects need cleanup](#stage-2-composables-with-side-effects-need-cleanup)\n- [Stage 3: composables that call other composables](#stage-3-composables-that-call-other-composables)\n- [Stage 4: when shared state is what you actually want](#stage-4-when-shared-state-is-what-you-actually-want)\n- [Edge cases and gotchas](#edge-cases-and-gotchas)\n- [Best practices](#best-practices)\n- [FAQ](#faq)\n- [Cheat sheet](#cheat-sheet)\n\n## The problem: two counters, one number\n\nHere's the composable, written the way it looks in a hundred tutorials:\n\n```js\n\u002F\u002F useCounter.js\nimport { ref } from 'vue'\n\nconst count = ref(0) \u002F\u002F looks like \"the counter's state\" — but whose?\n\nexport function useCounter() {\n  function increment() {\n    count.value++\n  }\n  return { count, increment }\n}\n```\n\nAnd here's a component that uses it, dropped onto a page twice:\n\n```vue\n\u003C!-- Counter.vue -->\n\u003Cscript setup>\nimport { useCounter } from '.\u002FuseCounter'\nconst { count, increment } = useCounter()\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cbutton @click=\"increment\">Count: {{ count }}\u003C\u002Fbutton>\n\u003C\u002Ftemplate>\n```\n\n```vue\n\u003C!-- Page.vue -->\n\u003Ctemplate>\n  \u003CCounter \u002F>\n  \u003CCounter \u002F>\n\u003C\u002Ftemplate>\n```\n\nClick the first button five times. Both buttons read `Count: 5`. Nothing crashed, nothing warned you — `useCounter()` did precisely what its source says: return the one `count` that exists.\n\nThe bug is not in `\u003CCounter \u002F>`. It's a single misplaced line in `useCounter.js` — and to see which line, you need the rule that explains both the broken version and the correct one.\n\n## The mental model: a composable is just a function\n\n**A composable has no special relationship to \"a component's state.\" It's a plain JavaScript function, and JavaScript's own scoping rules decide everything.**\n\n- State declared **inside** the function body is created fresh every time the function *runs*. Since `\u003CCounter \u002F>` calls `useCounter()` once per component instance, each instance gets its own `ref` — private state.\n- State declared **outside** the function — at module scope, in the file that defines it — is created exactly **once**, the first time anything imports the module. Every subsequent call to `useCounter()` returns a reference to that same `ref`. That's not a bug in the composable pattern; it's how ES modules always work, for the same reason a module-level `let cache = new Map()` is shared by every caller too.\n\nVue's Composition API rules (`ref`, `reactive`, `onMounted`, `provide`\u002F`inject`, …) never asked *where* you put your `const`. You decided that, with ordinary variable scope, before Vue ever entered the picture. The composable naming convention (`useXxx`) signals *what it does*, not *how many copies of it exist* — that second question is answered by reading exactly one thing: is this `ref()` call inside the exported function, or outside it?\n\nThat's the whole model. Everything below is a consequence of it.\n\n## Stage 1: state that lives inside the function\n\nMove `count` inside `useCounter`, and each call becomes its own closure over its own `ref`:\n\n```js\n\u002F\u002F useCounter.js — fixed\nimport { ref } from 'vue'\n\nexport function useCounter(start = 0) {\n  const count = ref(start) \u002F\u002F created fresh, once per CALL, not once per FILE\n  function increment() {\n    count.value++\n  }\n  return { count, increment }\n}\n```\n\n**Key concept:** nothing about the export changed — same function name, same return shape. Only the *scope* of `count` moved from module-level to function-level. Now two `\u003CCounter \u002F>` instances each run `useCounter()` independently, each closing over its own `count`, and clicking one leaves the other alone.\n\nThis is the default you want almost every time. If you're writing a composable and you haven't deliberately decided otherwise, put the state inside the function.\n\n## Stage 2: composables with side effects need cleanup\n\nComposables aren't only about state — they often start something that needs to be stopped. A classic:\n\n```js\n\u002F\u002F useMousePosition.js\nimport { ref, onMounted, onUnmounted } from 'vue'\n\nexport function useMousePosition() {\n  const x = ref(0)\n  const y = ref(0)\n\n  function update(e) {\n    x.value = e.clientX\n    y.value = e.clientY\n  }\n\n  onMounted(() => window.addEventListener('mousemove', update))\n  onUnmounted(() => window.removeEventListener('mousemove', update)) \u002F\u002F \u003C- easy to forget\n\n  return { x, y }\n}\n```\n\nDrop the `onUnmounted` line, and every component that ever used this composable leaves a `mousemove` listener attached to `window` forever, each one still running its `update` closure against a `ref` nobody reads anymore. It's not a crash — it's a slow, silent leak that only shows up as \"the page gets janky after navigating around for a while.\"\n\n**Key concept:** a composable's lifecycle hooks (`onMounted`, `onUnmounted`, `onScopeDispose`) tie the side effect to whichever component (or, as Stage 3 shows, effect scope) called the composable. If you start something, you are responsible for stopping it in the matching hook — Vue does not infer the teardown from the setup.\n\n## Stage 3: composables that call other composables\n\nComposables compose — `useMousePosition` might be built from a lower-level `useEventListener`:\n\n```js\n\u002F\u002F useEventListener.js\nimport { onMounted, onUnmounted } from 'vue'\n\nexport function useEventListener(target, event, handler) {\n  onMounted(() => target.addEventListener(event, handler))\n  onUnmounted(() => target.removeEventListener(event, handler))\n}\n```\n\nThis works fine called from `\u003Cscript setup>`, because `onMounted`\u002F`onUnmounted` register against the *currently running* component instance — and during `setup()`, there is one. It breaks the moment you call it from somewhere that isn't a component, like a Pinia store's `setup` function running at app-init time, or a plain test file: there's no \"current instance\" for the hook to attach to, Vue logs a dev warning, and the callback simply never fires.\n\nThe general-purpose fix is `onScopeDispose`, which ties into Vue's [effect scope](https:\u002F\u002Fvuejs.org\u002Fapi\u002Freactivity-advanced.html#effectscope) instead of specifically a component instance — and a component's own setup function is itself one:\n\n```js\nimport { onScopeDispose } from 'vue'\n\nexport function useEventListener(target, event, handler) {\n  target.addEventListener(event, handler)\n  onScopeDispose(() => target.removeEventListener(event, handler))\n}\n```\n\n**Key concept:** `onScopeDispose` fires whenever the enclosing effect scope tears down — a component unmounting, or an `effectScope().stop()` call you make yourself outside any component. It's the one cleanup hook that works the same whether the composable is called from a component or from plain code, which is exactly what a composable meant to be reused outside components needs.\n\nIf you skipped [Vue Reactivity Explained](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij), the same destructuring rule from that article applies here too: if `useEventListener` returned a `reactive()` object instead of individual `ref`s, destructuring it in the caller would silently drop reactivity. Composables should almost always return an object of `ref`s (or use `toRefs` on a `reactive` one) so a caller can safely write `const { x, y } = useMousePosition()`.\n\n## Stage 4: when shared state is what you actually want\n\nSometimes module-scoped state is the correct design, not a bug — a logged-in user, a theme preference, a WebSocket connection every component should see the same instance of. The fix isn't \"always put state inside the function\"; it's \"put it outside the function on purpose, and say so\":\n\n```js\n\u002F\u002F useAuthUser.js — a deliberate singleton, not an accident\nimport { ref, readonly } from 'vue'\n\n\u002F\u002F Module scope, intentionally: every caller across the whole app\n\u002F\u002F shares this exact user. This is a global store, not per-call state.\nconst _user = ref(null)\n\nexport function useAuthUser() {\n  function login(user) {\n    _user.value = user\n  }\n  function logout() {\n    _user.value = null\n  }\n  \u002F\u002F readonly() stops callers from doing `user.value = ...` directly —\n  \u002F\u002F every write goes through login()\u002Flogout(), which is the whole point\n  \u002F\u002F of centralizing this state in the first place.\n  return { user: readonly(_user), login, logout }\n}\n```\n\n**Key concept:** the code that makes this a singleton is identical in shape to the bug from the opening example — a `ref` declared outside the function. The difference is entirely in intent and in guarding the write path. A comment explaining *why* this one is module-scoped is not decoration; it's the one piece of information a reader can't get from the code's shape, because the shape is the same as the accidental version.\n\n## Edge cases and gotchas\n\n- **Server-side rendering makes this bug worse, not just different.** In the browser, a module-scoped `ref` is shared across components in *one user's* page — annoying, but contained. On a Vue SSR server (Nuxt or a custom setup), a Node process typically renders many different users' requests using the *same* loaded module. A `ref` created at module scope is created once when the server starts, not once per request — so `useAuthUser`'s state can leak from the user who requested it first into the response sent to the next user. This is the single most important reason \"does this composable's state live inside or outside the function\" is worth getting right on purpose, not by habit.\n- **Testing a composable outside a component throws or warns.** Calling `onMounted`\u002F`onUnmounted` with no active component instance triggers a dev-mode warning and does nothing. Wrap the call in `effectScope()` when unit-testing a composable directly, so lifecycle-style hooks (`onScopeDispose`) have somewhere to attach:\n  ```js\n  import { effectScope } from 'vue'\n  const scope = effectScope()\n  scope.run(() => {\n    const { count, increment } = useCounter()\n    increment()\n    expect(count.value).toBe(1)\n  })\n  scope.stop() \u002F\u002F triggers any onScopeDispose cleanup registered inside\n  ```\n- **Hot Module Replacement can hide the bug in dev.** Vite's HMR sometimes re-executes a module and resets its module-scope state, which can make a shared-state leak look fixed after a save — then reappear on a full reload. Don't trust \"it works now\" from an HMR session; reload the page.\n- **`getCurrentInstance()` is not the fix.** It's tempting to reach for it to detect \"am I inside a component,\" but it's explicitly documented as an internal API for advanced\u002Flibrary use, not a general escape hatch — prefer designing the composable so it doesn't need to know.\n\n## Best practices\n\n- **Default to state inside the function.** Only hoist state to module scope when you specifically want one shared instance across the whole app.\n- **Say so when you do.** A one-line comment (\"module-scope singleton, shared across the app\") costs nothing and saves the next reader from re-deriving your intent from scope rules.\n- **Return `ref`s, not a raw `reactive()` object**, so callers can destructure without losing reactivity.\n- **Clean up every side effect** a composable starts — a listener, a timer, a subscription — with `onUnmounted` for component-only composables, `onScopeDispose` for anything meant to be reusable outside a component.\n- **For real app-wide state, reach for Pinia** once it grows past one or two values. It gives you the same \"one shared store\" idea, but with devtools time-travel, SSR-safe store instantiation per request, and a name (`defineStore`) that makes \"this is intentionally shared\" obvious from the import alone.\n\n## FAQ\n\n### Why didn't Vue warn me that my composable's state was shared?\n\nBecause there's nothing to warn about — `const count = ref(0)` at module scope is completely valid JavaScript and Vue reactivity, doing exactly what it's written to do. The \"bug\" is a mismatch between your intent and the code's actual scope, which no linter can read your mind about.\n\n### Does this affect `provide`\u002F`inject` the same way?\n\nNo — `provide`\u002F`inject` is scoped to the component tree by design; each provider creates its own value for its own descendants, so two unrelated component trees don't share it. The module-scope trap is specific to state declared directly in a `.js` file outside any function.\n\n### Is a composable just a React-style custom hook?\n\nThey solve the same problem — extracting stateful logic into a reusable function — but the mechanics differ. A composable's `ref`s are proxies you can read anywhere without a special calling rule, and there is no \"rules of hooks\" about call order; a custom hook's state is closed over by React's fiber and re-runs the whole function on every render. The shared-state trap in this article has no React equivalent, because React hooks don't have a module-scope option — `useState` is always per-component.\n\n### Should every composable clean up after itself, even ones that don't touch the DOM?\n\nIf it started anything — a listener, an interval, a subscription to something outside Vue's reactivity — yes. If it only computed derived values from its inputs (`computed`, plain functions), there's nothing to clean up; `computed` refs are garbage-collected with everything else that stops referencing them.\n\n### Can I tell from the outside whether a composable shares state?\n\nOnly by reading its source (or its docs). The call site — `const { count, increment } = useCounter()` — looks identical either way. That asymmetry is exactly why Stage 4's naming\u002Fcomment convention matters: the information isn't visible where you'd need it most.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-composables-shared-state-trap\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## Cheat sheet\n\n| Pattern | Code | Result |\n|---|---|---|\n| Per-caller state (default) | `ref()` declared **inside** the exported function | Each call to `useX()` gets its own private state |\n| Shared\u002Fsingleton state | `ref()` declared **outside** the function, at module scope | Every call to `useX()` returns the same state — intentional or not |\n| Safe destructuring | Return individual `ref`s: `return { count, increment }` | Caller can destructure without losing reactivity |\n| Component-only cleanup | `onUnmounted(() => …)` | Runs when the owning component unmounts |\n| Reusable-anywhere cleanup | `onScopeDispose(() => …)` | Runs when the enclosing effect scope stops — component or manual `effectScope()` |\n| Protect a singleton's writes | `return { user: readonly(_user), login, logout }` | Callers can read but must go through your functions to write |\n| Test a composable in isolation | `effectScope().run(() => { … })`, then `.stop()` | Gives lifecycle-style hooks somewhere to attach outside a component |\n| SSR danger sign | A module-scope `ref` holding per-user data (auth, cart, …) | Can leak between requests on a server — make it request-scoped or use Pinia |\n\n## Key takeaways\n\n- A composable is a function first; JavaScript's own variable scope — not anything Vue-specific — decides whether its state is private per call or one shared singleton.\n- State inside the function body is the safe default; state outside it is a deliberate design choice that deserves a comment.\n- Every side effect a composable starts needs a matching cleanup, via `onUnmounted` or the more general `onScopeDispose`.\n- The same \"module scope = shared\" rule that causes two counters to double-count in the browser can leak one user's data into another user's response on an SSR server — treat it as a correctness issue, not just a UI quirk.\n\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-composables-shared-state-trap\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## Ending\n\nThose two counters that moved together weren't broken — they were exactly one `ref` doing exactly what a `ref` declared outside a function always does. Once you can see that scope decision sitting in the source, every \"why are these two components sharing state\" question stops being a mystery and starts being a one-line answer you can find by reading, not debugging.\n\nWhere has this bitten you — a shared composable, or the SSR version of it? Tell me in the comments.\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":108,"canonical":466,"description":109},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-composables-shared-state-trap","01a066ea-3098-75c9-b7e5-208a0701b1b5",{"name":469,"part":43,"total":43,"items":470},"Vue Deep Dive",[471,476,480],{"slug":472,"title":473,"publishedAt":474,"readingMinutes":475},"vue-weekly-reactivity-ref-vs-reactive","Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)","2026-08-24T13:57:38.080Z",14,{"slug":477,"title":478,"publishedAt":479,"readingMinutes":92},"vue-weekly-nexttick-batching-dom-updates","Vue nextTick Explained: How DOM Updates Are Batched","2026-09-14T12:19:00.606Z",{"slug":46,"title":108,"publishedAt":111,"readingMinutes":88},{"id":482,"locked":18},"01a066ea-30bb-712e-bbc1-795202e5b50b",[484],{"id":45,"slug":46,"title":48,"_count":485},{"questions":51},[487],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":489,"questionCount":51},{"questions":51}]