@ospm/eslint-plugin-react-signals-hooks
v1.6.5
Published
ESLint plugin for React Signals hooks - enforces best practices, performance optimizations, and integration patterns for @preact/signals-react usage in React projects
Maintainers
Readme
ESLint Plugin React Signals Hooks
A comprehensive ESLint plugin for React applications using @preact/signals-react. This plugin provides specialized rules to ensure proper signal usage, optimal performance, and adherence to React signals best practices, along with support for popular validation libraries.
Features
🎯 Signal Validation
- 24 specialized rules for React signals
- Complete replacement for
eslint-plugin-react-hooks/exhaustive-depsrule - Enhanced
exhaustive-depswith signal awareness
🚀 Performance Optimization
- Automatic batching of signal updates
- Optimized dependency tracking
- Efficient signal access patterns
- Minimal runtime overhead
Rules Overview
This plugin provides 24 specialized ESLint rules for React signals:
| Rule | Purpose | Autofix | Severity |
|------|---------|---------|----------|
| exhaustive-deps | Enhanced dependency detection for React hooks with @preact/signals-react support | ✅ | Error |
| require-use-signals | Enforces useSignals() hook in components using signals | ✅ | Error |
| no-mutation-in-render | Prevents signal mutations during render phase | ✅ | Error |
| no-signal-creation-in-component | Prevents signal creation in render | ✅ | Error |
| no-signal-assignment-in-effect | Prevents signal assignments in effects without deps | ✅ | Error |
| no-non-signal-with-signal-suffix | Ensures variables with 'Signal' suffix are signals | ✅ | Warning |
| prefer-batch-updates | Prefers batched updates for multiple signal changes | ✅ | Warning |
| prefer-computed | Suggests computed() over useMemo | ✅ | Warning |
| prefer-for-over-map | Suggests For component over .map() | ✅ | Warning |
| prefer-show-over-ternary | Suggests Show component for ternaries | ✅ | Warning |
| prefer-signal-effect | Prefers effect() over useEffect | ✅ | Warning |
| prefer-signal-in-jsx | Prefers direct signal usage in JSX | ✅ | Warning |
| prefer-signal-methods | Enforces signal methods over properties | ✅ | Warning |
| prefer-signal-reads | Optimizes signal access patterns | ✅ | Warning |
| prefer-use-signal-over-use-state | Suggests useSignal over useState | ✅ | Warning |
| prefer-use-signal-ref-over-use-ref | Suggests useSignalRef over useRef when .current is read during render | ✅ | Warning |
| restrict-signal-locations | Controls where signals can be created | ✅ | Error |
| signal-variable-name | Enforces signal naming conventions | ✅ | Error |
| warn-on-unnecessary-untracked | Warns about unnecessary untracked() usage | ✅ | Warning |
| prefer-use-computed-in-react-component | Encourages using useComputed(...) inside React components | ✅ | Warning |
| prefer-use-signal-effect-in-react-component | Encourages using useSignalEffect(...) inside React components | ✅ | Warning |
| forbid-signal-re-assignment | Forbids aliasing or re-assigning variables that hold a signal | ❌ | Error |
| forbid-signal-destructuring | Forbids destructuring signals into aliases (e.g., { value } = signal) | ❌ | Error |
| forbid-signal-update-in-computed | Forbids updating signals inside computed(...) callbacks to keep them pure/read-only | ❌ | Error |
For detailed examples and options for each rule, see the docs in docs/rules/.
prefer-use-signal-ref-over-use-refdocsprefer-for-over-mapdocsforbid-signal-re-assignmentdocsforbid-signal-destructuringdocsforbid-signal-update-in-computeddocsprefer-use-computed-in-react-componentdocsprefer-use-signal-effect-in-react-componentdocs
forbid-signal-destructuring options (summary)
modules?: string[]- Additional module specifiers to treat as exporting signal creators.
- Works with aliased and namespaced imports.
enableSuffixHeuristic?: boolean(default:false)- When
true, enables suffix-based detection (e.g., variables ending withSignal) as a fallback. - Can increase false positives; keep off unless needed.
- When
forbid-signal-re-assignment options (summary)
modules?: string[]- Additional module specifiers to treat as exporting signal creators. Merged with defaults (
@preact/signals-react,@preact/signals-core).
- Additional module specifiers to treat as exporting signal creators. Merged with defaults (
allowBareNames?: boolean(default:false)- When
true, treats bare identifierssignal/computed/effectas creators even without imports. Can increase false positives; keep disabled unless needed.
- When
suffix?: string(default:"Signal")- Variable-name suffix used as a heuristic when identifying signal-like variables.
Rule spotlight: prefer-use-signal-ref-over-use-ref
Encourages useSignalRef from @preact/signals-react/utils instead of useRef when .current is read during render/JSX.
❌ Incorrect
import { useRef } from 'react';
function Example() {
const divRef = useRef<HTMLDivElement | null>(null);
return <div ref={divRef}>{divRef.current}</div>; // render read
}✅ Correct
import { useSignalRef } from '@preact/signals-react/utils';
function Example() {
const divRef = useSignalRef<HTMLDivElement | null>(null);
return <div ref={divRef}>{divRef.current}</div>;
}Autofix performs:
- Add import:
useSignalReffrom@preact/signals-react/utils(augments existing import when possible) - Replace call:
useRef(...)→useSignalRef(...) - Rename variable and references: appends or replaces suffix with
SignalRef
Example rename (autofixed):
// Before
const inputRef = useRef<HTMLInputElement | null>(null);
<input ref={inputRef} onFocus={() => inputRef.current?.select()} />
// After (autofixed)
import { useSignalRef } from '@preact/signals-react/utils';
const inputSignalRef = useSignalRef<HTMLInputElement | null>(null);
<input ref={inputSignalRef} onFocus={() => inputSignalRef.current?.select()} />Option:
onlyWhenReadInRender(default:true) — only warn when.currentis read in render/JSX. Imperative-only usage (effects/handlers) is ignored.
Key Features
🎯 Smart Signal Detection
- Detects signal usage patterns across all React hooks
- Handles complex property chains and computed member expressions
- Excludes inner-scope variables from dependency requirements
- Supports both direct signals and
.valueproperty access
🔧 Comprehensive Autofix
- Automatically fixes missing dependencies
- Removes redundant and unnecessary dependencies
- Adds required imports (
useSignals,Show,For) - Transforms variable names to follow conventions
🛡️ Context-Aware Analysis
- Distinguishes between JSX and hook contexts
- Detects render vs. effect contexts for mutation rules
- Handles event handlers, callbacks, and nested functions
- Supports both React hooks and signals-core patterns
Installation
Install the plugin alongside @preact/signals-react:
# npm
npm install --save-dev @ospm/eslint-plugin-react-signals-hooks @preact/signals-react
# yarn
yarn add --dev @ospm/eslint-plugin-react-signals-hooks @preact/signals-react
# pnpm
pnpm add -D @ospm/eslint-plugin-react-signals-hooks @preact/signals-reactYou must disable the react-hooks/exhaustive-deps rule when using this plugin, since this plugin provides a signal-aware replacement:
{
"rules": {
"react-hooks/exhaustive-deps": "off"
}
}Configuration
ESLint Flat Config (Recommended)
// eslint.config.js
import reactSignalsHooks from '@ospm/eslint-plugin-react-signals-hooks';
export default [
{
plugins: {
'react-signals-hooks': reactSignalsHooks,
},
rules: {
// Enhanced dependency detection
'react-signals-hooks/exhaustive-deps': [
'error',
{
enableAutoFixForMemoAndCallback: true,
enableDangerousAutofixThisMayCauseInfiniteLoops: false,
},
],
// Signal usage enforcement
'react-signals-hooks/require-use-signals': 'error',
'react-signals-hooks/signal-variable-name': 'error',
'react-signals-hooks/no-mutation-in-render': 'error',
// Performance and best practices
'react-signals-hooks/prefer-signal-in-jsx': 'warn',
'react-signals-hooks/prefer-show-over-ternary': 'warn',
'react-signals-hooks/prefer-for-over-map': 'warn',
'react-signals-hooks/prefer-signal-effect': 'warn',
'react-signals-hooks/prefer-computed': 'warn',
'react-signals-hooks/prefer-use-signal-ref-over-use-ref': 'warn',
},
},
];Legacy ESLint Config
// .eslintrc.js
module.exports = {
plugins: ['react-signals-hooks'],
rules: {
// Enhanced dependency detection
'react-signals-hooks/exhaustive-deps': [
'error',
{
enableAutoFixForMemoAndCallback: true,
enableDangerousAutofixThisMayCauseInfiniteLoops: false,
},
],
// Signal usage enforcement
'react-signals-hooks/require-use-signals': 'error',
'react-signals-hooks/signal-variable-name': 'error',
'react-signals-hooks/no-mutation-in-render': 'error',
// Performance and best practices
'react-signals-hooks/prefer-signal-in-jsx': 'warn',
'react-signals-hooks/prefer-show-over-ternary': 'warn',
'react-signals-hooks/prefer-for-over-map': 'warn',
'react-signals-hooks/prefer-signal-effect': 'warn',
'react-signals-hooks/prefer-computed': 'warn',
},
};Rule Documentation
1. exhaustive-deps - Enhanced Dependency Detection
Extends React's exhaustive-deps rule with signal-aware dependency detection.
Options:
enableAutoFixForMemoAndCallback(boolean, default:false) - Enable autofix for safe hooksenableDangerousAutofixThisMayCauseInfiniteLoops(boolean, default:false) - Enable autofix for effect hooks
// ❌ Missing signal dependency
const value = useMemo(() => countSignal.value * 2, []);
// ✅ Fixed
const value = useMemo(() => countSignal.value * 2, [countSignal.value]);2. require-use-signals - Enforce useSignals Hook
Requires components using signals to call useSignals() for optimal performance.
// ❌ Missing useSignals()
function Component() {
return <div>{countSignal.value}</div>;
}
// ✅ With useSignals()
function Component() {
const store = useSignals(1);
try {
return <div>{countSignal.value}</div>;
} finally {
store.f();
}
}3. no-mutation-in-render - Prevent Render Mutations
Prevents signal mutations during component render phase.
// ❌ Mutation during render
function Component() {
countSignal.value++; // Error: mutation in render
return <div>{countSignal.value}</div>;
}
// ✅ Mutation in effect
function Component() {
useEffect(() => {
countSignal.value++; // OK: mutation in effect
}, []);
return <div>{countSignal.value}</div>;
}4. no-signal-creation-in-component - Prevent Signal Creation in Render
Prevents signal creation inside component render functions.
// ❌ Signal creation in render
function Component() {
const countSignal = signal(0); // Error: signal created in render
return <div>{countSignal.value}</div>;
}
// ✅ Signal created at module level or in hooks
const countSignal = signal(0);
function Component() {
return <div>{countSignal.value}</div>;
}5. no-signal-assignment-in-effect - Safe Effect Dependencies
Prevents signal assignments in effects without proper dependency tracking.
// ❌ Unsafe signal assignment in effect
useEffect(() => {
countSignal.value = 42; // Missing dependency
}, []);
// ✅ With proper dependency
useEffect(() => {
countSignal.value = 42;
}, [someDependency]);6. no-non-signal-with-signal-suffix - Consistent Naming
Ensures variables with 'Signal' suffix are actual signal instances.
// ❌ Incorrect usage of Signal suffix
const dataSignal = { value: 42 }; // Not a real signal
// ✅ Correct usage
const dataSignal = signal(42);7. prefer-batch-updates - Batch Signal Updates
Encourages batching multiple signal updates.
// ❌ Separate updates
function handleClick() {
setA(a + 1);
setB(b + 1);
}
// ✅ Batched updates (autofixed)
function handleClick() {
batch(() => {
setA(a + 1);
setB(b + 1);
});
}8. prefer-computed - Computed Values
Prefers computed() over useMemo for signal-derived values.
// ❌ useMemo with only signal dependencies
const doubled = useMemo(() => countSignal.value * 2, [countSignal.value]);
// ✅ Using computed() (autofixed)
const doubled = computed(() => countSignal.value * 2);Note: inside React components, prefer useComputed() to avoid creating a new signal on each render:
// ✅ In components, use the hook variant
const doubled = useComputed(() => countSignal.value * 2);9. prefer-for-over-map - For Component
Suggests using For component over .map() for better performance with signal arrays.
// ❌ Using .map() in JSX
{itemsSignal.value.map(item => <div key={item.id}>{item.name}</div>)}
// ✅ Using For component (autofixed)
<For each={itemsSignal}>
{item => <div>{item.name}</div>}
</For>10. prefer-show-over-ternary - Show Component
Suggests using Show component for conditional rendering with signals.
// ❌ Complex ternary with signal
{visibleSignal.value ? (
<div>Content</div>
) : null}
// ✅ Using Show component (autofixed)
<Show when={visibleSignal.value}>
<div>Content</div>
</Show>11. prefer-signal-effect - Signal Effects
Prefers effect() over useEffect when dependencies are only signals.
// ❌ useEffect with only signal dependencies
useEffect(() => {
console.log(countSignal.value);
}, [countSignal.value]);
// ✅ Using effect() (autofixed)
effect(() => {
console.log(countSignal.value);
});Note: inside React components, prefer useSignalEffect() to integrate with React lifecycle:
// ✅ In components, use the hook variant
useSignalEffect(() => {
console.log(countSignal.value);
});12. prefer-signal-in-jsx - Direct Signal Usage
Prefers direct signal usage over .value in JSX.
// ❌ Using .value in JSX
<div>{messageSignal.value}</div>
// ✅ Direct signal usage (autofixed)
<div>{messageSignal}</div>13. prefer-signal-methods - Signal Methods
Encourages using signal methods over direct property access.
// ❌ Direct property access
const value = signal(0);
value.value = 1;
// ✅ Using methods (autofixed)
const value = signal(0);
value.set(1);14. prefer-signal-reads - Optimized Signal Reading
Optimizes signal access patterns for better performance.
// ❌ Multiple signal reads
function double() {
return countSignal.value * 2;
}
// ✅ Single read (autofixed)
function double() {
const count = countSignal.value;
return count * 2;
}15. prefer-use-signal-over-use-state - Signal State
Suggests useSignal over useState for primitive values.
// ❌ Using useState for primitive
const [count, setCount] = useState(0);
// ✅ Using useSignal (autofixed)
const countSignal = useSignal(0);16. restrict-signal-locations - Signal Scope Control
Controls where signals can be created in the codebase.
Options:
allowedPaths: Array of glob patterns where signals can be createddisallowedPaths: Array of glob patterns where signals cannot be created
// ❌ Signal creation in disallowed location
function Component() {
const signal = createSignal(0); // Error: Signal creation not allowed here
return <div>{signal.value}</div>;
}17. signal-variable-name - Naming Conventions
Enforces consistent naming for signal variables.
Rules:
- Must end with 'Signal'
- Must start with lowercase
- Must not start with 'use'
// ❌ Incorrect naming
const counter = signal(0);
const useDataSignal = signal('');
// ✅ Correct naming (autofixed)
const counterSignal = signal(0);
const dataSignal = signal('');18. warn-on-unnecessary-untracked - Optimize Untracked Usage
Warns about unnecessary untracked() usage.
// ❌ Unnecessary untracked
const value = untracked(() => countSignal.value);
// ✅ Direct access is sufficient
const value = countSignal.value;Usage Examples
✅ Correct Usage
import { useCallback, useMemo, useEffect } from 'react';
import { counterSignal, nameSignal } from './signals';
function MyComponent() {
const count = counterSignal.value;
const name = nameSignal.value;
// ✅ Correct: includes signal.value dependencies
const memoizedValue = useMemo(() => {
return count * 2 + name.length;
}, [count, name]); // or [counterSignal.value, nameSignal.value]
// ✅ Correct: useRef is stable, no dependency needed
const ref = useRef(null);
const callback = useCallback(() => {
ref.current?.focus();
}, []); // useRef values are stable
// ✅ Correct: computed member expressions
const data = useMemo(() => {
return items.find(item => item.id === selectedId);
}, [items, selectedId]); // selectedId from outer scope
return <div>{memoizedValue}</div>;
}❌ Incorrect Usage (Will be flagged)
// ❌ Missing signal.value dependencies
const memoizedValue = useMemo(() => {
return counterSignal.value * 2;
}, []); // Missing: counterSignal.value
// ❌ Using base signal instead of .value
const memoizedValue = useMemo(() => {
return counterSignal.value * 2;
}, [counterSignal]); // Should be: [counterSignal.value]
// ❌ Redundant base dependency
const memoizedValue = useMemo(() => {
return counterSignal.value * 2;
}, [counterSignal, counterSignal.value]); // counterSignal is redundant
// ❌ Including useRef unnecessarily
const ref = useRef(null);
const callback = useCallback(() => {
ref.current?.focus();
}, [ref]); // useRef values should not be dependenciesAdvanced Patterns
Complex Signal Dependencies
// ✅ Handles deep property access and computed members
const value = useMemo(() => {
return hexagonsSignal.value[hexId].neighbors?.top?.id;
}, [hexagonsSignal.value[hexId].neighbors?.top?.id, hexId]);
// ✅ Inner-scope variable exclusion
const filtered = useMemo(() => {
return items.filter((item) => {
return signalMap.value[item.id]; // item.id is inner-scope
});
}, [signalMap.value, items]); // item.id not requiredAssignment vs. Read Detection
// ✅ Assignment-only: no signal dependency needed
const updateData = useCallback(() => {
dataSignal.value[key] = newValue;
}, [key, newValue]);
// ✅ Read + assignment: signal dependency required
const conditionalUpdate = useCallback(() => {
if (!dataSignal.value[key]) {
dataSignal.value[key] = newValue;
}
}, [dataSignal.value[key], key, newValue]);Supported Hooks and Patterns
- ✅
useMemo,useCallback,useEffect,useLayoutEffect - ✅ Custom hooks following the
use*pattern - ✅
effect()andcomputed()from@preact/signals-core - ✅ Signal property chains and computed member expressions
- ✅ Inner-scope variable detection in callbacks
TypeScript Support
Fully compatible with TypeScript and @typescript-eslint/parser.
- Works with both Flat config (
eslint.config.js) and legacy.eslintrc. - Understands TS syntax features (generics,
as const, enums, type-only imports). - Analyzes computed members and property chains with proper type info when available.
Parser setup example (legacy config):
// .eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: { project: ['./tsconfig.json'] }, // optional but recommended for best type-aware results
plugins: ['react-signals-hooks'],
rules: {
'react-signals-hooks/exhaustive-deps': 'error',
},
};Flat config example:
// eslint.config.js
import tseslint from 'typescript-eslint';
import reactSignalsHooks from '@ospm/eslint-plugin-react-signals-hooks';
export default [
...tseslint.config({
parserOptions: { project: ['./tsconfig.json'] }, // optional
}),
{
plugins: { 'react-signals-hooks': reactSignalsHooks },
rules: { 'react-signals-hooks/exhaustive-deps': 'error' },
},
];Note: a project reference is not strictly required, but it can improve precision for complex expressions.
Compatibility
- React 18+
@preact/signals-react2.x+- Node.js 18+
- ESLint 8.56+ (legacy) or 9+ (Flat config)
IDE Integration
- Works out of the box with VS Code ESLint extension.
- Autofixes are safe-by-default. For aggressive fixes in effects, enable:
enableDangerousAutofixThisMayCauseInfiniteLoopsinexhaustive-depsoptions. - Many rules provide code actions and rename-safe fixes (imports/identifiers updated consistently).
Performance
- Uses targeted AST visitors and avoids repeated traversal.
- Skips inner-scope variables for dependency inference to reduce noise.
- Provides internal performance tracking hooks in rule implementations.
Tips:
- Run ESLint with
--cachein CI and locally. - Prefer Flat config for faster startup in ESLint 9+.
Limitations
- The plugin does not execute code; dynamic patterns may require manual review.
- If you heavily customize signal creators or use unusual abstractions, consider enabling
modulesand/orenableSuffixHeuristicoptions in relevant rules. - Aggressive autofix for effects can introduce loops if your effect writes to values it also reads; keep
enableDangerousAutofixThisMayCauseInfiniteLoopsoff unless you know the codebase patterns.
FAQ
- Why replace
react-hooks/exhaustive-deps? — React Hooks rules are not signal-aware. This plugin understands.value, computed chains, and signal semantics to provide accurate deps and safer fixes. - Can I keep both rules on? — No. Disable
react-hooks/exhaustive-depsto avoid conflicts and duplicate/contradictory diagnostics. - Does it support custom hooks? — Yes. Any
use*function is analyzed as a hook callback site for dependency inference.
Contributing
Contributions are welcome! Please open an issue or PR. Follow the repository’s lint/test conventions, and add rule docs under packages/eslint-plugin-react-signals-hooks/docs/rules/ following the structure described in rules.md.
License
MIT © OSPM
