@ospm/eslint-plugin-react-signals-hooks
Version:
ESLint plugin for React Signals hooks - enforces best practices, performance optimizations, and integration patterns for @preact/signals-react usage in React projects
738 lines (551 loc) β’ 22.7 kB
Markdown
# 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-deps` rule
- Enhanced `exhaustive-deps` with 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-ref` docs](./docs/rules/prefer-use-signal-ref-over-use-ref.md)
- [`prefer-for-over-map` docs](./docs/rules/prefer-for-over-map.md)
- [`forbid-signal-re-assignment` docs](./docs/rules/forbid-signal-re-assignment.md)
- [`forbid-signal-destructuring` docs](./docs/rules/forbid-signal-destructuring.md)
- [`forbid-signal-update-in-computed` docs](./docs/rules/forbid-signal-update-in-computed.md)
- [`prefer-use-computed-in-react-component` docs](./docs/rules/prefer-use-computed-in-react-component.md)
- [`prefer-use-signal-effect-in-react-component` docs](./docs/rules/prefer-use-signal-effect-in-react-component.md)
### `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 with `Signal`) as a fallback.
- Can increase false positives; keep off unless needed.
### `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`).
- `allowBareNames?: boolean` (default: `false`)
- When `true`, treats bare identifiers `signal`/`computed`/`effect` as creators even without imports. Can increase false positives; keep disabled unless needed.
- `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
```tsx
import { useRef } from 'react';
function Example() {
const divRef = useRef<HTMLDivElement | null>(null);
return <div ref={divRef}>{divRef.current}</div>; // render read
}
```
β
Correct
```tsx
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: `useSignalRef` from `@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):
```tsx
// 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 `.current` is 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 `.value` property 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`:
```bash
# 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-react
```
You must disable the `react-hooks/exhaustive-deps` rule when using this plugin, since this plugin provides a signal-aware replacement:
```json
{
"rules": {
"react-hooks/exhaustive-deps": "off"
}
}
```
## Configuration
### ESLint Flat Config (Recommended)
```javascript
// 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
```javascript
// .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 hooks
- `enableDangerousAutofixThisMayCauseInfiniteLoops` (boolean, default: `false`) - Enable autofix for effect hooks
```tsx
// β 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.
```tsx
// β 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.
```tsx
// β 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.
```tsx
// β 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.
```tsx
// β 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.
```tsx
// β 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.
```tsx
// β 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.
```tsx
// β 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:
```tsx
// β
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.
```tsx
// β 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.
```tsx
// β 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.
```tsx
// β 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:
```tsx
// β
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.
```tsx
// β 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.
```tsx
// β 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.
```tsx
// β 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.
```tsx
// β 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 created
- `disallowedPaths`: Array of glob patterns where signals cannot be created
```tsx
// β 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'
```tsx
// β 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.
```tsx
// β Unnecessary untracked
const value = untracked(() => countSignal.value);
// β
Direct access is sufficient
const value = countSignal.value;
```
## Usage Examples
### β
**Correct Usage**
```tsx
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)**
```tsx
// β 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 dependencies
```
## Advanced Patterns
### Complex Signal Dependencies
```tsx
// β
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 required
```
### Assignment vs. Read Detection
```tsx
// β
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()` and `computed()` 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):
```js
// .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:
```js
// 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-react` 2.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: `enableDangerousAutofixThisMayCauseInfiniteLoops` in `exhaustive-deps` options.
- 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 `--cache` in 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 `modules` and/or `enableSuffixHeuristic` options in relevant rules.
- Aggressive autofix for effects can introduce loops if your effect writes to values it also reads; keep `enableDangerousAutofixThisMayCauseInfiniteLoops` off 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-deps` to 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