eslint-plugin-mvvm
v1.0.2
Published
ESLint plugin enforcing MVVM architectural layer boundaries
Maintainers
Readme
eslint-plugin-mvvm
ESLint plugin that enforces MVVM architectural layer boundaries in React codebases.
Data flows one way — Model → ViewModel → View — and these rules keep it that way:
state usage and API calls in Views are flagged as ESLint errors and pushed into
ViewModel hooks where they belong.
Installation
npm install --save-dev eslint-plugin-mvvmRequires Node.js >= 18 and ESLint >= 8 (flat config, ESLint 9 recommended).
Usage
// eslint.config.js
import mvvm from 'eslint-plugin-mvvm';
export default [
mvvm.configs.recommended, // or mvvm.configs.strict
];Or configure rules individually:
import mvvm from 'eslint-plugin-mvvm';
export default [
{
plugins: { mvvm },
rules: {
'mvvm/no-api-in-view': 'error',
'mvvm/no-state-in-view': ['error', { mode: 'strict' }],
'mvvm/no-jsx-in-viewmodel': 'error',
'mvvm/enforce-layer-boundaries': 'error',
},
},
];Rules
| Rule | Description |
| --- | --- |
| mvvm/no-api-in-view | Disallows API calls (fetch, axios.*, TanStack Query, RTK Query, SWR, Apollo hooks) in View files. Data fetching belongs in ViewModel hooks. A callee that resolves to the ViewModel layer or is named like a ViewModel (e.g. useEditDialogViewModel) is never flagged; use the ignorePattern option as an extra escape hatch. |
| mvvm/no-state-in-view | Disallows useState / useReducer in View files. strict mode bans all state; warn-business mode only flags state that coexists with an API call in the same file (pure UI state like isOpen stays legal). |
| mvvm/no-jsx-in-viewmodel | ViewModel files must return data, not JSX. |
| mvvm/enforce-layer-boundaries | Enforces import direction: Views import ViewModels, ViewModels import Models — never the reverse, and Views never import Models directly (optionally relaxed for import type). |
What counts as a View / ViewModel / Model?
- View — any
.tsx/.jsxfile that isn't classified as a ViewModel. - ViewModel —
useXxx.ts(x)hooks,*.vm.*,*.viewmodel.*,*ViewModel.*, or anything underhooks/,viewmodels/,view-models/. - Model — files under
models/,stores/,api/,services/,repositories/,domain/, or*.model.ts/*.service.ts/*.store.ts.
enforce-layer-boundaries resolves relative imports to their file on disk so
the real extension drives classification. The directory hints
(viewDirPatterns) only apply to a path that can't be resolved to a concrete
.ts/.tsx/.js/.jsx file, and are matched relative to the project root
(see below) — so an extension-less ../useThing that points at a ViewModel
hook is treated as a ViewModel, not misclassified as a View.
Conventions are overridable via shared settings:
export default [
mvvm.configs.recommended,
{
settings: {
mvvm: {
viewModelPatterns: ['\\.vm\\.', 'ViewModel\\.'],
viewDirPatterns: ['/components/', '/pages/'],
modelPatterns: ['/api/', '\\.service\\.ts$'],
// Base path for directory-hint matching. Defaults to the ESLint
// working directory, so a hint like `/ui/` only fires on directories
// *inside* the project, not an ancestor path that is named `ui/`.
root: import.meta.dirname,
},
},
},
];Pass an explicit empty array to opt out of a category entirely — for example
viewDirPatterns: [] disables the View directory hints (an omitted value falls
back to the defaults above).
Configs
recommended— pragmatic defaults for incremental adoption. API calls and business state in Views are errors;no-state-in-viewruns inwarn-businessmode so pure UI state is still allowed.strict— zero tolerance. No state at all in Views, noimport typeexceptions from Model.
Development
bun install
bun run lint # eslint
bun test # bun's test runner
bun run build # tsc → dist/Releases are automated: merging a package.json version bump to master tags,
signs, and publishes the package with npm provenance (see
.github/workflows/release.yml).
