ESLint Plugin
@usels/eslint-plugin provides ESLint rules that enforce Legend-State best practices — catching common bugs and style violations before they reach runtime.
Installation
Section titled “Installation”npm install -D @usels/eslint-plugin eslint@^9Requires ESLint v9 with flat config format (
eslint.config.js).
Quick Setup
Section titled “Quick Setup”Recommended config
Section titled “Recommended config”Enables all rules. Phase 1 rules are errors; Phase 2 rules are warnings.
import legendPlugin from '@usels/eslint-plugin';
export default [ legendPlugin.configs.recommended,];Strict config
Section titled “Strict config”All rules at error severity.
import legendPlugin from '@usels/eslint-plugin';
export default [ legendPlugin.configs.strict,];Manual setup
Section titled “Manual setup”Pick only the rules you want:
import legendPlugin from '@usels/eslint-plugin';
export default [ { plugins: { 'use-legend': legendPlugin }, rules: { 'use-legend/observable-naming': 'error', 'use-legend/no-observable-in-jsx': 'error', 'use-legend/hook-return-naming': 'warn', 'use-legend/no-enable-api': 'warn', 'use-legend/no-reactive-hoc': 'warn', 'use-legend/prefer-show-for-conditional': 'warn', 'use-legend/prefer-for-component': 'warn', 'use-legend/prefer-use-observable': 'warn', 'use-legend/prefer-use-observe': 'warn', }, },];Phase 1 — Errors (high confidence)
Section titled “Phase 1 — Errors (high confidence)”| Rule | Description |
|---|---|
observable-naming | Variables holding observables must end with $ |
no-observable-in-jsx | Call .get() on observables in JSX expressions |
Phase 2 — Warnings (style & best practice)
Section titled “Phase 2 — Warnings (style & best practice)”| Rule | Description |
|---|---|
hook-return-naming | Preserve $ suffix when renaming destructured fields |
no-enable-api | Avoid global enable* configuration APIs |
no-reactive-hoc | Use <Show>/<For>/<Memo> instead of HOCs |
prefer-show-for-conditional | Use <Show> over &&/ternary with observable conditions |
prefer-for-component | Use <For> over .map() on observable arrays |
prefer-use-observable | Use useObservable over useState |
prefer-use-observe | Use useObserve/useObserveEffect over useEffect |
Rule Details
Section titled “Rule Details”observable-naming
Section titled “observable-naming”Variables holding observables must end with $.
// ❌ Errorconst count = useObservable(0);const data = observable({ name: 'foo' });
// ✅ Goodconst count$ = useObservable(0);const data$ = observable({ name: 'foo' });Default tracked functions:
| Package | Functions |
|---|---|
@legendapp/state | observable, computed |
@legendapp/state/react | useObservable, useObservableState |
@usels/web | all exported use* hooks |
@usels/native | all exported use* hooks |
Options:
"use-legend/observable-naming": ["error", { "trackFunctions": { /* per-package function names */ }, "allowPattern": null // regex to exempt specific names}]no-observable-in-jsx
Section titled “no-observable-in-jsx”Observables used directly in JSX render [object Object]. Always call .get().
// ❌ Error — renders "[object Object]"<div>{count$}</div><span>{user$.name}</span>
// ✅ Good<div>{count$.get()}</div><span>{user$.name.get()}</span>
// ✅ Good — Legend-State components accept observables intentionally<Show if={isLoading$}><Spinner /></Show><For each={items$}>{(item$) => <li>{item$.name.get()}</li>}</For>Default allowed props:
Show:if,ifReady,elseFor:eachSwitch:value- All elements:
ref(foruseRef$)
Options:
"use-legend/no-observable-in-jsx": ["error", { "allowedJsxComponents": ["Show", "For", "Switch", "Memo", "Computed"], "allowedProps": { "Show": ["if", "ifReady", "else"], "For": ["each"] }, "allowedGlobalProps": ["ref"]}]hook-return-naming
Section titled “hook-return-naming”When destructuring $-suffixed fields, the renamed binding must also end with $.
// ❌ Warning — $ suffix lostconst { x$: x, isDragging$: dragging } = useDraggable(target$);
// ✅ Good — keep shorthandconst { x$, isDragging$ } = useDraggable(target$);
// ✅ Good — rename with $ preservedconst { x$: posX$, isDragging$: dragging$ } = useDraggable(target$);no-enable-api
Section titled “no-enable-api”Legend-State’s enable* APIs mutate global state and conflict with fine-grained reactivity patterns.
// ❌ Warningimport { enable$GetSet } from '@legendapp/state/config/enable$GetSet';enable$GetSet(); // conflicts with $ suffix convention
import { enableReactTracking } from '@legendapp/state/config/enableReactTracking';enableReactTracking({ auto: true }); // whole-component re-renders
// ✅ Good — use explicit .get() / .set()const value = count$.get();count$.set(value + 1);Flagged APIs: enable$GetSet, enable_PeekAssign, enableReactTracking, enableReactUse, enableReactComponents, enableReactNativeComponents
no-reactive-hoc
Section titled “no-reactive-hoc”HOCs like observer() make the entire component reactive, causing whole-component re-renders.
// ❌ Warning — whole component re-rendersimport { observer } from '@legendapp/state/react';const MyComponent = observer(() => <div>{count$.get()}</div>);
// ✅ Good — only Memo re-rendersfunction MyComponent() { return <div><Memo>{() => count$.get()}</Memo></div>;}prefer-show-for-conditional
Section titled “prefer-show-for-conditional”&& / || / ternary with an observable condition causes the parent component to re-render. Use <Show> for fine-grained updates.
// ❌ Warning{isLoading$.get() && <Spinner />}{isActive$ ? <A /> : <B />}
// ✅ Good<Show if={isLoading$}><Spinner /></Show><Show if={isActive$} else={<B />}><A /></Show>Note: Complex comparisons like
{count$.get() > 0 && <Badge />}are not detected by this rule. Use<Show if={() => count$.get() > 0}>manually for these cases.
prefer-for-component
Section titled “prefer-for-component”.get().map() on an observable array re-renders all items on any change. <For> re-renders only the changed item.
// ❌ Warning{items$.get().map((item) => <li key={item.id}>{item.name}</li>)}
// ✅ Good<For each={items$}> {(item$) => <li>{item$.name.get()}</li>}</For>prefer-use-observable
Section titled “prefer-use-observable”useState causes full component re-renders. useObservable provides fine-grained reactivity and eliminates setter functions.
// ❌ Warningconst [count, setCount] = useState(0);
// ✅ Goodconst count$ = useObservable(0);count$.set(c => c + 1); // no setter neededUse allowPatterns to exempt UI-only state:
"use-legend/prefer-use-observable": ["warn", { "allowPatterns": ["^is[A-Z]", "^(open|show|visible)"]}]prefer-use-observe
Section titled “prefer-use-observe”All useEffect calls are flagged. useObserve/useObserveEffect auto-track observable dependencies — no dependency array needed.
// ❌ Warning — all useEffect calls flaggeduseEffect(() => { document.title = user$.name.get();}, [user$.name.get()]);
// ✅ Good — auto-tracks user$.nameuseObserve(() => { document.title = user$.name.get();});
// ✅ Good — with cleanupuseObserveEffect(() => { const unsub = count$.onChange(syncToServer); return unsub;});Configs Reference
Section titled “Configs Reference”recommended
Section titled “recommended”import legendPlugin from '@usels/eslint-plugin';export default [legendPlugin.configs.recommended];| Rule | Severity |
|---|---|
observable-naming | error |
no-observable-in-jsx | error |
hook-return-naming | warn |
no-enable-api | warn |
no-reactive-hoc | warn |
prefer-show-for-conditional | warn |
prefer-for-component | warn |
prefer-use-observable | warn |
prefer-use-observe | warn |
strict
Section titled “strict”All rules at error severity. Recommended for greenfield projects fully committed to fine-grained reactivity.
import legendPlugin from '@usels/eslint-plugin';export default [legendPlugin.configs.strict];TypeScript Integration
Section titled “TypeScript Integration”The plugin is written in TypeScript and ships with type definitions. No extra configuration is required. Parser is provided separately:
npm install -D @typescript-eslint/parserimport legendPlugin from '@usels/eslint-plugin';import tsParser from '@typescript-eslint/parser';
export default [ { languageOptions: { parser: tsParser }, ...legendPlugin.configs.recommended, },];- Enable
observable-namingfirst. Several rules (no-observable-in-jsx,prefer-show-for-conditional,prefer-for-component) rely on the$suffix to detect observables. Without it, they may miss some cases. - Use
allowPatternsfor UI-only state.prefer-use-observableandprefer-use-observecan be noisy in existing codebases. UseallowPatterns/allowListto introduce them incrementally. - Pair with the Babel/Vite plugin.
@usels/vite-plugin-legend-memoauto-wraps.get()calls in<Memo>, makingprefer-show-for-conditionalwarnings less urgent for simple cases.