Hard lesson 03

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.

Level Intermediate Stack Vue 3 · TypeScript · Pinia Verified against two production frontends

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.

textfrontend-a/src
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.

A directory is a naming convention, not a boundary

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. …

Dan Abramov, 2015 · bullet lists condensed, marks each omission medium.com/@dan_abramov/smart-and-dumb-components

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.

Dan Abramov, 2019 editor's note on the same article medium.com/@dan_abramov/smart-and-dumb-components

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.

Obsolete: the file split

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".

Survives: the dependency rule

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."
The inversion this page exists for

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.

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.

LayerMay import a storeMay do transportGets data viaTalks up via
Route / container
views/
Yes: this is the boundary Through the store Store + route params Router
Stateful composable
composables/stateful/
Yes Through the store Store Returned refs
Pure composable
composables/
No No Arguments Return value
Presentational
components/
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.

Hidden coupling components/AreaCard.vue declared presentational import composables/useAreaFilters no store in sight (here) import stores/area · Pinia global state the leaf depends on global state one hop away, invisible in review Store boundary at the container views/AreaBoardView.vue route-level container useAreaFilters store-aware: allowed stores/area global state props ↓ emit ↑ AreaCard.vue props in, events out the leaf depends on its props and on nothing else
Left: the component imports no store, and is coupled to one anyway. The red path is real dependency; the fact that it runs through a composable changes nothing about the leaf's blast radius, only about who can see it. Right: the same composable, the same store, called one level up. The leaf's entire contract is its props and its emits, which is why it can be rendered in a test, a Storybook story, or a second route with no Pinia instance at all.

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

Two Vue 3 + TypeScript + Pinia frontends, measured with one regex

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/*.ts

Run 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.

bashmeasure-the-boundary.sh
# 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
What this instrument cannot see

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.

vuesrc/components/area/AreaCard.vue
<!-- 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>

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.

Where prop-drilling is the right complaint

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.

tseslint.config.js
// 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.

The documented hole in this rule

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:

Canonical sources