Skip to content

ESLint Plugin

@usels/eslint-plugin provides ESLint rules that enforce Legend-State best practices — catching common bugs and style violations before they reach runtime.


Terminal window
npm install -D @usels/eslint-plugin eslint@^9

Requires ESLint v9 with flat config format (eslint.config.js).


Enables all rules. Phase 1 rules are errors; Phase 2 rules are warnings.

eslint.config.js
import legendPlugin from '@usels/eslint-plugin';
export default [
legendPlugin.configs.recommended,
];

All rules at error severity.

eslint.config.js
import legendPlugin from '@usels/eslint-plugin';
export default [
legendPlugin.configs.strict,
];

Pick only the rules you want:

eslint.config.js
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',
},
},
];

RuleDescription
observable-namingVariables holding observables must end with $
no-observable-in-jsxCall .get() on observables in JSX expressions

Phase 2 — Warnings (style & best practice)

Section titled “Phase 2 — Warnings (style & best practice)”
RuleDescription
hook-return-namingPreserve $ suffix when renaming destructured fields
no-enable-apiAvoid global enable* configuration APIs
no-reactive-hocUse <Show>/<For>/<Memo> instead of HOCs
prefer-show-for-conditionalUse <Show> over &&/ternary with observable conditions
prefer-for-componentUse <For> over .map() on observable arrays
prefer-use-observableUse useObservable over useState
prefer-use-observeUse useObserve/useObserveEffect over useEffect

Variables holding observables must end with $.

// ❌ Error
const count = useObservable(0);
const data = observable({ name: 'foo' });
// ✅ Good
const count$ = useObservable(0);
const data$ = observable({ name: 'foo' });

Default tracked functions:

PackageFunctions
@legendapp/stateobservable, computed
@legendapp/state/reactuseObservable, useObservableState
@usels/weball exported use* hooks
@usels/nativeall exported use* hooks

Options:

"use-legend/observable-naming": ["error", {
"trackFunctions": { /* per-package function names */ },
"allowPattern": null // regex to exempt specific names
}]

Full docs →


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, else
  • For: each
  • Switch: value
  • All elements: ref (for useRef$)

Options:

"use-legend/no-observable-in-jsx": ["error", {
"allowedJsxComponents": ["Show", "For", "Switch", "Memo", "Computed"],
"allowedProps": { "Show": ["if", "ifReady", "else"], "For": ["each"] },
"allowedGlobalProps": ["ref"]
}]

Full docs →


When destructuring $-suffixed fields, the renamed binding must also end with $.

// ❌ Warning — $ suffix lost
const { x$: x, isDragging$: dragging } = useDraggable(target$);
// ✅ Good — keep shorthand
const { x$, isDragging$ } = useDraggable(target$);
// ✅ Good — rename with $ preserved
const { x$: posX$, isDragging$: dragging$ } = useDraggable(target$);

Full docs →


Legend-State’s enable* APIs mutate global state and conflict with fine-grained reactivity patterns.

// ❌ Warning
import { 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

Full docs →


HOCs like observer() make the entire component reactive, causing whole-component re-renders.

// ❌ Warning — whole component re-renders
import { observer } from '@legendapp/state/react';
const MyComponent = observer(() => <div>{count$.get()}</div>);
// ✅ Good — only Memo re-renders
function MyComponent() {
return <div><Memo>{() => count$.get()}</Memo></div>;
}

Full docs →


&& / || / 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.

Full docs →


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

Full docs →


useState causes full component re-renders. useObservable provides fine-grained reactivity and eliminates setter functions.

// ❌ Warning
const [count, setCount] = useState(0);
// ✅ Good
const count$ = useObservable(0);
count$.set(c => c + 1); // no setter needed

Use allowPatterns to exempt UI-only state:

"use-legend/prefer-use-observable": ["warn", {
"allowPatterns": ["^is[A-Z]", "^(open|show|visible)"]
}]

Full docs →


All useEffect calls are flagged. useObserve/useObserveEffect auto-track observable dependencies — no dependency array needed.

// ❌ Warning — all useEffect calls flagged
useEffect(() => {
document.title = user$.name.get();
}, [user$.name.get()]);
// ✅ Good — auto-tracks user$.name
useObserve(() => {
document.title = user$.name.get();
});
// ✅ Good — with cleanup
useObserveEffect(() => {
const unsub = count$.onChange(syncToServer);
return unsub;
});

Full docs →


import legendPlugin from '@usels/eslint-plugin';
export default [legendPlugin.configs.recommended];
RuleSeverity
observable-namingerror
no-observable-in-jsxerror
hook-return-namingwarn
no-enable-apiwarn
no-reactive-hocwarn
prefer-show-for-conditionalwarn
prefer-for-componentwarn
prefer-use-observablewarn
prefer-use-observewarn

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];

The plugin is written in TypeScript and ships with type definitions. No extra configuration is required. Parser is provided separately:

Terminal window
npm install -D @typescript-eslint/parser
eslint.config.js
import legendPlugin from '@usels/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
export default [
{
languageOptions: { parser: tsParser },
...legendPlugin.configs.recommended,
},
];

  • Enable observable-naming first. 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 allowPatterns for UI-only state. prefer-use-observable and prefer-use-observe can be noisy in existing codebases. Use allowPatterns / allowList to introduce them incrementally.
  • Pair with the Babel/Vite plugin. @usels/vite-plugin-legend-memo auto-wraps .get() calls in <Memo>, making prefer-show-for-conditional warnings less urgent for simple cases.