Strict Smart/Dumb Components
A components/ directory and a views/ directory prove nothing about
coupling. The interesting part is not the split itself, which the pattern's own author
has since walked back, but the constraint underneath it, and the fact that in Vue 3
the composable is simultaneously the best way to honour that constraint and the easiest
way to violate it without anyone noticing.
The claim under test
Almost every Vue codebase of consequence is laid out like this, and almost every team that owns one will tell you they separate smart components from dumb ones. The directory names say so.
src/
├── components/ 114 .vue files ← "presentational"
│ ├── area/
│ ├── charts/
│ └── layout/
├── views/ 14 .vue files ← "containers", one per route
├── composables/ 71 .ts files
└── stores/ Pinia
One regular expression settles it. Counting .vue files that mention
storeToRefs or a use*Store factory: 50.9% of the
files in components/ reach into a Pinia store, against 50.0% of
the files in views/. Not a smaller number. The same number.
That is the sharpest possible result, because it is not a near miss. If the two layers touch global state at identical rates, the directory boundary carries literally zero information about what is inside a file. "We follow smart/dumb components" described the dir tree, not the code.
Nothing in Vue, Vite, TypeScript or Pinia enforces a rule about which directory may import which. Until something does, whether a lint rule, a dependency-graph check or a failing build, the separation exists only in the heads of the people who happen to remember it, and it decays at exactly the rate that team membership turns over.
The original pattern, and its author's retraction
The canonical statement is Dan Abramov's 2015 article. It is worth reading his definition precisely, because most repetitions of it are vaguer than the original and drop the clause that actually matters.
Presentational components are concerned with how things look. … Have no dependencies on the rest of the app, such as Flux actions or stores. Don't specify how the data is loaded or mutated. Receive data and callbacks exclusively via props. Rarely have their own state (when they do, it's UI state rather than data). …
Container components are concerned with how things work. … Provide the data and behavior to presentational or other container components. Call Flux actions and provide these as callbacks to the presentational components. Are often stateful, as they tend to serve as data sources. …
Four years later Abramov added a note to the top of that same article. It is not a footnote or a clarification; it is a retraction of the structural advice, and any honest treatment of this pattern has to lead with it.
I wrote this article a long time ago and my views have since evolved. In particular, I don't suggest splitting your components like this anymore. If you find it natural in your codebase, this pattern can be handy. But I've seen it enforced without any necessity and with almost dogmatic fervor far too many times.
The main reason I found it useful was because it let me separate complex stateful logic from other aspects of the component. Hooks let me do the same thing without an arbitrary division. This text is left intact for historical reasons but don't take it too seriously.
patterns.dev reaches the same conclusion from the other direction, listing the replacement rather than the regret:
In many cases, the Container/Presentational pattern can be replaced with React Hooks.
Read the retraction carefully and you will notice it retires exactly one half of the article. The half it retires is the mechanical one.
Two files per feature, a wrapper whose only job is to pass its own props through, a
views/ directory maintained because the guide said so. Abramov's own word
for the outcome: enforcement "without any necessity".
The bullet the retraction does not touch, and the only one worth enforcing:
"Have no dependencies on the rest of the app, such as Flux actions or stores."
Abramov's replacement for the pattern is Hooks. Composables are Vue's Hooks. So the mechanism offered as the escape from the arbitrary file division is precisely the mechanism that reintroduces the coupling the division was invented to prevent, because a composable can import anything, including a store, and the component that calls it shows no trace of that. Dropping the file split without constraining what a composable may import leaves you with neither the old discipline nor the new one.
The rule, stated so it can be tested
Discard "smart" and "dumb": they are adjectives, and adjectives cannot fail a build. What remains is three mechanical properties of a source file. A component either has them or it does not, and a script can tell you which.
- Imports nothing from
stores/, and nothing frompinia. NouseXStore(), nostoreToRefs, at any depth of its import graph. - Imports no transport. No
axios, nofetchwrapper, no base URL, no knowledge that a network exists. - Receives every piece of data through
defineProps, typed, with no fallback path to get it another way. - Communicates upward only through
defineEmits. It reports what happened; it does not decide what follows. - Owns local UI state only: open/closed, hovered, the draft text in an input. That is not application state and it never was.
Two of these are underwritten by the framework rather than by taste. Vue's props are, in the
documentation's words, "a one-way-down binding between the child property
and the parent one: when the parent property updates, it will flow down to the child, but not
the other way around", and Vue warns in the console when a child tries to write back. The
upward channel is deliberately narrow: defineEmits carries a name and a payload,
not a reference to anything the parent owns.
| Layer | May import a store | May do transport | Gets data via | Talks up via |
|---|---|---|---|---|
Route / containerviews/ |
Yes: this is the boundary | Through the store | Store + route params | Router |
Stateful composablecomposables/stateful/ |
Yes | Through the store | Store | Returned refs |
Pure composablecomposables/ |
No | No | Arguments | Return value |
Presentationalcomponents/ |
No | No | defineProps |
defineEmits |
The composable leak
This is the part almost every article on the subject omits, and it is the part that decides
whether your separation is real. Vue's own guide defines a composable as "a function that
leverages Vue's Composition API to encapsulate and reuse stateful logic", and says nothing
about which stateful logic, because it cannot. A composable is an ordinary module. It may
import a store. Pinia is explicit that a store is instantiated when called "within a component
<script setup> (or within setup() like all composables)",
which is precisely what makes the hop invisible: the call site looks identical either way.
So a component whose import list contains not one reference to stores/ can still
be fully coupled to global state. Every measurement that greps components for
use*Store reports it as clean. Every reviewer scanning the imports approves it.
The trade-off is worth naming rather than glossing. You can make useAreaFilters
pure by having it take the refs it needs as arguments and import nothing, and then it is safe to
call from anywhere, including a leaf. The price is that every call site must thread those refs
through, which is real friction and the reason teams stop doing it. The alternative kept here
is cheaper and equally enforceable: let the composable own the store, and constrain
who may call it.
What this looked like in production
The same pattern, /storeToRefs|use[A-Za-z]*Store/ over .vue
files, was run across both codebases, so the percentages are directly comparable.
Frontend A (the newer and larger of the two):
src/components/: 58 of 114 files (50.9%) import a Pinia store.src/views/: 7 of 14 (50.0%).- Identical rates. "Components" were not presentational and "views" were not uniquely containers; the two directories were interchangeable with respect to global state.
And 50.9% is a floor, not a total. Separately, 36 component files imported a composable, and 40 of the 71 composables themselves imported a store. The overlap between those 36 and the 58 direct importers was not measured, so the two figures cannot be added, but the direction is unambiguous: more components reached global state than the headline regex could see, through the indirect path.
Frontend B (older, and the surprise):
src/components/: 27 of 85 (31.8%);src/views/: 7 of 12 (58.3%).- A real gradient. 68% of components (58 of 85) were genuinely presentational by this test. The older codebase separated better than the newer one.
- But several components bypassed the store layer entirely and issued their own HTTP calls with their own environment-provided base URLs. That is a worse leak than reading a store, because it escapes the view layer and the store layer at once: no single place knows the request happened, and no single place can mock it.
The lesson is not "the old code was better". It is that separation degrades silently as a codebase grows unless something measures it, and that the indirect composable path is the one nobody measures at all.
src/components/**/*.vue src/views/**/*.vue src/composables/*.tsRun the same thing on your own repository before you argue about it. Four commands give you the headline; the last two give you the number that actually matters.
# Every .vue file under components/ that reaches a Pinia store DIRECTLY.
rg -l --glob '*.vue' 'storeToRefs|use[A-Za-z]*Store' src/components | wc -l
rg --files --glob '*.vue' src/components | wc -l # the denominator
# The same measurement on the layer that is ALLOWED to know about stores.
# If the two percentages match, the directory split is decorative.
rg -l --glob '*.vue' 'storeToRefs|use[A-Za-z]*Store' src/views | wc -l
rg --files --glob '*.vue' src/views | wc -l
# --- the indirect path, which almost nobody measures ----------------------
# 1. Which composables are themselves store-aware?
rg -l 'storeToRefs|use[A-Za-z]*Store' src/composables \
| xargs -n1 basename | sed 's/\.[jt]s$//' | sort -u > tainted.txt
# 2. Which components import one of those? rg -f reads one pattern per line.
rg -l --glob '*.vue' -f tainted.txt src/components | wc -l
A regex over text misses dynamic import(), state handed down through
provide/inject, and composables that are themselves clean but call
a second composable that is not. Every number above is therefore a lower bound. A page whose
thesis is "measure rather than assume" owes you the blind spots of its own measurement.
If you want the transitive answer rather than the one-hop answer, you need an import-graph
tool, not rg.
Doing it properly
Three files. The first is the leak as it is normally written. Note that only some of it is visible. Lines marked in red are what a reviewer catches; the pair marked in indigo is the coupling that survives review untouched, because the import that carries it names a composable, not a store.
<!-- Filed under components/, therefore presentational. Allegedly. -->
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAreaStore } from '@/stores/area'
import { useAreaFilters } from '@/composables/useAreaFilters'
const props = defineProps<{ areaId: string }>()
// Leak 1, direct: the card now knows a global store exists, which one it
// is, and how its state is shaped. Three facts of coupling in two lines.
const store = useAreaStore()
const { areas, savingId } = storeToRefs(store)
// Leak 2, indirect: the one that survives review. useAreaFilters imports
// the very same store. Nothing on line 17 says so; the import list is clean.
const { isVisible } = useAreaFilters()
const area = computed(() => areas.value.find(a => a.id === props.areaId))
function rename(name: string) {
// Leak 3: a leaf writing to global state. The parent cannot intercept it,
// cannot roll it back, and cannot render this component without Pinia.
store.rename(props.areaId, name)
}
</script>
<template>
<article v-if="area && isVisible(area)" class="card">
<h3>{{ area.name }}</h3>
<button :disabled="savingId === area.id" @click="rename('Renamed')">Rename</button>
</article>
</template>
<!-- Presentational: nothing from stores/, nothing from api/, no composable. -->
<script setup lang="ts">
import { computed } from 'vue'
import type { AreaSummary } from '@/types/area'
// The props type is this component's entire contract with the outside world.
// Change it and every call site fails to compile, which is the whole point.
const props = withDefaults(defineProps<{
area: AreaSummary
saving?: boolean
}>(), { saving: false })
const emit = defineEmits<{
(e: 'rename', payload: { id: string; name: string }): void
(e: 'select', id: string): void
}>()
// Local state is allowed: this is UI state, not application state.
const label = computed(() => (props.saving ? 'Saving…' : 'Rename'))
</script>
<template>
<article class="card" @click="emit('select', area.id)">
<h3>{{ area.name }}</h3>
<button :disabled="saving" @click.stop="emit('rename', { id: area.id, name: 'Renamed' })">
{{ label }}
</button>
</article>
</template>
<!-- Route-level container: the only layer permitted to import a store. -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { useAreaStore } from '@/stores/area'
import { useAreaFilters } from '@/composables/stateful/useAreaFilters'
import AreaCard from '@/components/area/AreaCard.vue'
const router = useRouter()
// storeToRefs lives here and only here: state keeps its reactivity through
// destructuring, while actions come off the store directly, because Pinia binds them.
const store = useAreaStore()
const { savingId } = storeToRefs(store)
const { rename, load } = store
// A store-aware composable is legitimate at this level because it sits inside
// the boundary. Importing it from components/ is what the lint rule forbids.
const { visibleAreas } = useAreaFilters()
onMounted(load)
function onRename(payload: { id: string; name: string }) {
rename(payload.id, payload.name)
}
function onSelect(id: string) {
router.push({ name: 'area', params: { id } })
}
</script>
<template>
<section class="board">
<AreaCard
v-for="area in visibleAreas"
:key="area.id"
:area="area"
:saving="savingId === area.id"
@rename="onRename"
@select="onSelect"
/>
</section>
</template>
The second tab is worth one more look. It imports vue and a type. That is the
complete list. It can be mounted with a plain object and asserted against without a Pinia
instance, without a router, and without a network stub. That testability is not a bonus,
it is the same property as the decoupling, observed from a different angle.
Threading a value through four intermediate components that do nothing with it is a genuine cost, and the honest answer is not "use the store anyway". It is that four layers of pass-through usually means the container is at the wrong level: push the boundary down to the component that actually owns the subtree. A container is not required to be a route. Reaching for the store to avoid two props trades a visible cost for an invisible one.
Making it enforceable
A convention that lives in a style guide is a convention that will be 50.9% observed. The rule
has to fail a build. ESLint's no-restricted-imports, "Disallow specified modules
when loaded by import", expresses the whole boundary in one flat-config block,
because the rule accepts gitignore-style path patterns and applies them per files
glob.
// Flat config. This is what turns "we follow smart/dumb components" from a
// claim about the dir tree into a build failure.
import pluginVue from 'eslint-plugin-vue'
const NO_GLOBAL_STATE = {
// Both spellings, deliberately. A team that bans only the '@/' alias gets
// routed around by '../../stores/area' inside a week.
group: ['pinia', '@/stores/*', '**/stores/*'],
message: 'Global state is not available at this layer. The container owns the store; receive the value as a prop or an argument.'
}
const NO_TRANSPORT = {
group: ['axios', 'ofetch', '@/api/*', '**/api/*', '**/services/http*'],
message: 'No transport at this layer. Emit an event or return a value; the container performs the call.'
}
export default [
...pluginVue.configs['flat/recommended'],
{
// One rules object per glob. A second config object with the SAME `files`
// would replace this rule rather than merge with it.
files: ['src/components/**/*.vue'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
NO_GLOBAL_STATE,
NO_TRANSPORT,
{
// The composable leak, made syntactic. Store-aware composables live
// in exactly one directory, and components may not reach into it.
group: ['@/composables/stateful/*', '**/composables/stateful/*'],
message: 'This composable owns global state. Call it from the container and pass the result down as props.'
}
]
}]
}
},
{
// Closes the loop. Without this, nothing stops a store import landing in the
// unmarked composables directory, and then the rule above sees nothing at
// all. Note the single '*': composables/stateful/ is deliberately excluded.
files: ['src/composables/*.{ts,js}'],
rules: {
'no-restricted-imports': ['error', { patterns: [NO_GLOBAL_STATE, NO_TRANSPORT] }]
}
}
]
The two blocks are load-bearing together and neither works alone. The first stops a presentational component reaching a store, directly or through the quarantined directory. The second stops the quarantine from being trivially escaped by writing the store import into an ordinary composable instead. Miss the second and you have rebuilt the original hole with extra ceremony.
ESLint states it plainly: no-restricted-imports "applies to static imports only,
not dynamic ones". A const { useAreaStore } = await import('@/stores/area') walks
straight past every pattern above, as does require(). Treat the lint rule as the
thing that catches accidents, not the thing that stops a determined workaround. If you
need the guarantee, add an import-graph check that resolves the real module graph.
Lint catches shape. It cannot catch a component that receives a prop named
store. The remaining checks belong in review, and they are short enough to
actually get used:
- Does this component's import list contain anything other than
vue, types, and other presentational components? - For every composable it calls, open that composable. What does it import? This is the one step that is always skipped.
- Could this component be mounted in a test with a hand-written props object and no plugins?
- Does any prop carry a store, a store slice, or a function bound to a store, rather than plain data and callbacks?
- Does it decide what happens next, or does it report what happened and let the parent decide?
- Is the container at the right level, or is it a route by habit, four pass-through layers above the component that owns the subtree?
Canonical sources
- Dan Abramov: Presentational and Container Components · the original definition, with the author's 2019 retraction at the top. Read both.
- patterns.dev: Container/Presentational Pattern · states that in many cases the pattern can be replaced with Hooks, and lists what is lost.
- Vue 3: Composables · what a composable is, why each caller gets its own state, and the pointer to State Management for anything shared.
- Vue 3: Props · the one-way-down binding, and why a child may not write back.
- Vue 3: Component Events ·
defineEmits, including the type-only declaration syntax used above. - Pinia: Core concepts · why a store cannot be destructured directly, what
storeToRefsis for, and where a store may be instantiated. - ESLint: no-restricted-imports · the
patterns/groupoption, and the static-imports-only limitation.