diff --git a/.gitignore b/.gitignore
index 61450cc..350b148 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+.idea
+.idea/**/*
+
# Logs
logs
*.log
diff --git a/config/recommended.js b/config/recommended.js
new file mode 100644
index 0000000..e69de29
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..e69de29
diff --git a/src/index.ts b/src/index.ts
index bac8f24..58edc74 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -19,6 +19,7 @@ import noUnnecessaryCombination from "./rules/no-unnecessary-combination/no-unne
import noUnnecessaryDuplication from "./rules/no-unnecessary-duplication/no-unnecessary-duplication"
import noUselessMethods from "./rules/no-useless-methods/no-useless-methods"
import noWatch from "./rules/no-watch/no-watch"
+import preferSingleBinding from "./rules/prefer-single-binding/prefer-single-binding"
import preferUseUnit from "./rules/prefer-useUnit/prefer-useUnit"
import requirePickupInPersist from "./rules/require-pickup-in-persist/require-pickup-in-persist"
import strictEffectHandlers from "./rules/strict-effect-handlers/strict-effect-handlers"
@@ -47,6 +48,7 @@ const base = {
"prefer-useUnit": preferUseUnit,
"require-pickup-in-persist": requirePickupInPersist,
"strict-effect-handlers": strictEffectHandlers,
+ "prefer-single-binding": preferSingleBinding,
},
}
diff --git a/src/rules/prefer-single-binding/prefer-single-binding.md b/src/rules/prefer-single-binding/prefer-single-binding.md
new file mode 100644
index 0000000..5ac1ddc
--- /dev/null
+++ b/src/rules/prefer-single-binding/prefer-single-binding.md
@@ -0,0 +1,588 @@
+# effector/prefer-single-binding
+
+[Related documentation](https://effector.dev/en/api/effector-react/useunit/)
+
+Recommends combining multiple `useUnit` calls into a single call for better performance and cleaner code.
+
+## Rule Details
+
+This rule detects when multiple `useUnit` hooks are called in the same component and suggests combining them into a single call.
+
+Multiple `useUnit` calls can lead to:
+- **Performance overhead**: Each `useUnit` creates separate subscriptions without batch-updates
+- **Code duplication**: Repetitive hook calls make code harder to read
+- **Maintenance issues**: Harder to track all units used in a component
+
+### Examples
+
+```tsx
+// 👎 incorrect - multiple useUnit calls
+const Component = () => {
+ const [store] = useUnit([$store]);
+ const [event] = useUnit([$event]);
+
+ return ;
+};
+```
+
+```tsx
+// 👍 correct - single useUnit call
+const Component = () => {
+ const [store, event] = useUnit([$store, $event]);
+
+ return ;
+};
+```
+
+## Options
+
+This rule accepts an options object with the following properties:
+
+```typescript
+type Options = {
+ allowSeparateStoresAndEvents?: boolean;
+ enforceStoresAndEventsSeparation?: boolean;
+};
+```
+
+### `allowSeparateStoresAndEvents`
+
+**Default:** `false`
+
+When set to `true`, allows separate `useUnit` calls for stores and events, but still enforces combining multiple calls within each group.
+
+The rule uses heuristics to determine whether a unit is a store or an event:
+- **Stores**: Names starting with `$`, or matching patterns like `is*`, `has*`, `*Store`, `*State`, `data`, `value`, `items`
+- **Events**: Names ending with `*Event`, `*Changed`, `*Triggered`, `*Clicked`, `*Pressed`, or starting with `on*`, `handle*`, `set*`, `update*`, `submit*`
+
+#### Configuration
+
+```javascript
+// .eslintrc.js
+export default {
+ rules: {
+ 'effector/prefer-single-binding': ['warn', {
+ allowSeparateStoresAndEvents: true
+ }]
+ }
+};
+```
+
+#### Examples with `allowSeparateStoresAndEvents: true`
+
+```tsx
+// 👍 correct - separate groups for stores and events
+const Component = () => {
+ const [userName, userAge] = useUnit([$userName, $userAge]);
+ const [updateUser, deleteUser] = useUnit([updateUserEvent, deleteUserEvent]);
+
+ return (
+
+
{userName}, {userAge}
+
+
+
+ );
+};
+```
+
+```tsx
+// 👎 incorrect - multiple stores in separate calls
+const Component = () => {
+ const [userName] = useUnit([$userName]);
+ const [userAge] = useUnit([$userAge]);
+ const [updateUser, deleteUser] = useUnit([updateUserEvent, deleteUserEvent]);
+
+ return ...
;
+};
+```
+
+```tsx
+// 👎 incorrect - multiple events in separate calls
+const Component = () => {
+ const [userName, userAge] = useUnit([$userName, $userAge]);
+ const [updateUser] = useUnit([updateUserEvent]);
+ const [deleteUser] = useUnit([deleteUserEvent]);
+
+ return ...
;
+};
+```
+
+### `enforceStoresAndEventsSeparation`
+
+**Default:** `false`
+
+When set to `true`, enforces separation of stores and events into different `useUnit` calls. This option detects when a single `useUnit` call contains both stores and events and suggests splitting them.
+
+This is useful when you want to maintain clear logical separation between state (stores) and actions (events) in your components.
+
+#### Configuration
+
+```javascript
+// .eslintrc.js
+export default {
+ rules: {
+ 'effector/prefer-single-binding': ['warn', {
+ enforceStoresAndEventsSeparation: true
+ }]
+ }
+};
+```
+
+#### Examples with `enforceStoresAndEventsSeparation: true`
+
+```tsx
+// 👎 incorrect - mixed stores and events
+const Component = () => {
+ const [value, setValue] = useUnit([$store, event]);
+
+ return ;
+};
+```
+
+```tsx
+// 👍 correct - separated stores and events
+const Component = () => {
+ const [value] = useUnit([$store]);
+ const [setValue] = useUnit([event]);
+
+ return ;
+};
+```
+
+```tsx
+// 👎 incorrect - mixed in object form
+const Component = () => {
+ const { value, setValue } = useUnit({
+ value: $store,
+ setValue: event
+ });
+
+ return ;
+};
+```
+
+```tsx
+// 👍 correct - separated in object form
+const Component = () => {
+ const { value } = useUnit({ value: $store });
+ const { setValue } = useUnit({ setValue: event });
+
+ return ;
+};
+```
+
+### Combining both options
+
+You can use both options together to enforce a specific code style:
+
+```javascript
+// .eslintrc.js
+export default {
+ rules: {
+ 'effector/prefer-single-binding': ['warn', {
+ allowSeparateStoresAndEvents: true,
+ enforceStoresAndEventsSeparation: true
+ }]
+ }
+};
+```
+
+With both options enabled:
+- Mixed `useUnit` calls will be split into separate calls for stores and events
+- Multiple calls of the same type (stores or events) will be combined
+
+```tsx
+// 👎 incorrect - mixed types
+const Component = () => {
+ const [value1, setValue1, value2, setValue2] = useUnit([
+ $store1,
+ event1,
+ $store2,
+ event2
+ ]);
+
+ return null;
+};
+```
+
+```tsx
+// 👍 correct - separated and combined by type
+const Component = () => {
+ const [value1, value2] = useUnit([$store1, $store2]);
+ const [setValue1, setValue2] = useUnit([event1, event2]);
+
+ return null;
+};
+```
+
+#### Working with models
+
+This combination is especially useful when working with Effector models:
+
+```tsx
+// 👎 incorrect - mixed stores and events
+const Component = () => {
+ const [isFormSent, submit, reset, isLoading] = useUnit([
+ FormModel.$isFormSent,
+ FormModel.submitForm,
+ FormModel.resetForm,
+ FormModel.$isLoading,
+ ]);
+
+ return (
+
+ );
+};
+```
+
+```tsx
+// 👍 correct - stores and events are separated by logical groups
+const Component = () => {
+ // All stores from the model
+ const [isFormSent, isLoading] = useUnit([
+ FormModel.$isFormSent,
+ FormModel.$isLoading,
+ ]);
+
+ // All events from the model
+ const [submit, reset] = useUnit([
+ FormModel.submitForm,
+ FormModel.resetForm,
+ ]);
+
+ return (
+
+ );
+};
+```
+
+## Why is this important?
+
+### Performance
+
+Each `useUnit` call creates its own subscription management overhead. Combining them reduces:
+- Number of hook calls
+- Subscription management overhead
+- Re-render coordination complexity
+
+### Code clarity
+
+A single `useUnit` call (or logically separated calls) makes it easier to:
+- See all dependencies at a glance
+- Understand component's reactive logic
+- Maintain and refactor code
+
+## Array shape examples
+
+```tsx
+// 👎 incorrect
+const Component = () => {
+ const [userName] = useUnit([$userName]);
+ const [userAge] = useUnit([$userAge]);
+ const [updateUser] = useUnit([updateUserEvent]);
+
+ return (
+
+
{userName}, {userAge}
+
+
+ );
+};
+```
+
+```tsx
+// 👍 correct - combined (default behavior)
+const Component = () => {
+ const [userName, userAge, updateUser] = useUnit([
+ $userName,
+ $userAge,
+ updateUserEvent,
+ ]);
+
+ return (
+
+
{userName}, {userAge}
+
+
+ );
+};
+```
+
+```tsx
+// 👍 also correct - separated (with enforceStoresAndEventsSeparation: true)
+const Component = () => {
+ const [userName, userAge] = useUnit([$userName, $userAge]);
+ const [updateUser] = useUnit([updateUserEvent]);
+
+ return (
+
+
{userName}, {userAge}
+
+
+ );
+};
+```
+
+## Object shape examples
+
+```tsx
+// 👎 incorrect
+const Component = () => {
+ const { userName } = useUnit({ userName: $userName });
+ const { userAge } = useUnit({ userAge: $userAge });
+ const { updateUser } = useUnit({ updateUser: updateUserEvent });
+
+ return (
+
+
{userName}, {userAge}
+
+
+ );
+};
+```
+
+```tsx
+// 👍 correct - combined (default behavior)
+const Component = () => {
+ const { userName, userAge, updateUser } = useUnit({
+ userName: $userName,
+ userAge: $userAge,
+ updateUser: updateUserEvent,
+ });
+
+ return (
+
+
{userName}, {userAge}
+
+
+ );
+};
+```
+
+```tsx
+// 👍 also correct - separated (with enforceStoresAndEventsSeparation: true)
+const Component = () => {
+ const { userName, userAge } = useUnit({
+ userName: $userName,
+ userAge: $userAge,
+ });
+ const { updateUser } = useUnit({ updateUser: updateUserEvent });
+
+ return (
+
+
{userName}, {userAge}
+
+
+ );
+};
+```
+
+## Real-world example
+
+```tsx
+import React from "react";
+import { createEvent, createStore } from "effector";
+import { useUnit } from "effector-react";
+
+const $userName = createStore("John");
+const $userEmail = createStore("john@example.com");
+const $isLoading = createStore(false);
+const updateNameEvent = createEvent();
+const updateEmailEvent = createEvent();
+
+// 👎 incorrect - scattered useUnit calls (default behavior)
+const UserProfile = () => {
+ const [userName] = useUnit([$userName]);
+ const [userEmail] = useUnit([$userEmail]);
+ const [isLoading] = useUnit([$isLoading]);
+ const [updateName] = useUnit([updateNameEvent]);
+ const [updateEmail] = useUnit([updateEmailEvent]);
+
+ return (
+
+ );
+};
+
+// 👍 correct - single useUnit call (default behavior)
+const UserProfile = () => {
+ const [userName, userEmail, isLoading, updateName, updateEmail] = useUnit([
+ $userName,
+ $userEmail,
+ $isLoading,
+ updateNameEvent,
+ updateEmailEvent,
+ ]);
+
+ return (
+
+ );
+};
+
+// 👍 also correct - separated stores and events
+// (with allowSeparateStoresAndEvents: true or enforceStoresAndEventsSeparation: true)
+const UserProfile = () => {
+ const [userName, userEmail, isLoading] = useUnit([
+ $userName,
+ $userEmail,
+ $isLoading,
+ ]);
+
+ const [updateName, updateEmail] = useUnit([
+ updateNameEvent,
+ updateEmailEvent,
+ ]);
+
+ return (
+
+ );
+};
+```
+
+## Auto-fix
+
+This rule provides automatic fixes based on the configuration:
+
+### Default behavior
+When you run ESLint with the `--fix` flag, it will combine all `useUnit` calls into a single one:
+
+```bash
+eslint --fix your-file.tsx
+```
+
+### With `enforceStoresAndEventsSeparation: true`
+The auto-fix will split mixed `useUnit` calls into separate calls for stores and events:
+
+```tsx
+// Before
+const [value, setValue] = useUnit([$store, event]);
+
+// After auto-fix
+const [value] = useUnit([$store]);
+const [setValue] = useUnit([event]);
+```
+
+### With both options enabled
+The auto-fix will both split mixed calls and combine multiple calls of the same type:
+
+```tsx
+// Before
+const [value1] = useUnit([$store1]);
+const [value2, handler] = useUnit([$store2, event1]);
+const [handler2] = useUnit([event2]);
+
+// After auto-fix
+const [value1, value2] = useUnit([$store1, $store2]);
+const [handler, handler2] = useUnit([event1, event2]);
+```
+
+## Configuration examples
+
+### Strict single call (default)
+```javascript
+// .eslintrc.js
+export default {
+ rules: {
+ 'effector/prefer-single-binding': 'warn'
+ }
+};
+```
+
+### Allow stores/events separation
+```javascript
+// .eslintrc.js
+export default {
+ rules: {
+ 'effector/prefer-single-binding': ['warn', {
+ allowSeparateStoresAndEvents: true
+ }]
+ }
+};
+```
+
+### Enforce stores/events separation
+```javascript
+// .eslintrc.js
+export default {
+ rules: {
+ 'effector/prefer-single-binding': ['warn', {
+ enforceStoresAndEventsSeparation: true
+ }]
+ }
+};
+```
+
+### Enforce separation and combine duplicates
+```javascript
+// .eslintrc.js
+export default {
+ rules: {
+ 'effector/prefer-single-binding': ['warn', {
+ allowSeparateStoresAndEvents: true,
+ enforceStoresAndEventsSeparation: true
+ }]
+ }
+};
+```
+
+## When Not To Use It
+
+In rare cases, you might want to keep `useUnit` calls separate for specific reasons:
+
+```tsx
+/* eslint-disable effector/prefer-single-binding */
+const Component = () => {
+ const [userStore] = useUnit([$userStore]);
+
+ // Some complex logic that depends on userStore...
+ if (!userStore) return null;
+
+ const [settingsStore] = useUnit([$settingsStore]);
+
+ return null;
+};
+/* eslint-enable effector/prefer-single-binding */
+```
+
+However, even in these cases, consider refactoring to use a single `useUnit` call (or enabling the appropriate options) for better performance and clarity.
+
+## References
+
+- [useUnit API documentation](https://effector.dev/en/api/effector-react/useunit/)
+- [Effector React hooks best practices](https://effector.dev/en/api/effector-react/)
+```
diff --git a/src/rules/prefer-single-binding/prefer-single-binding.test.ts b/src/rules/prefer-single-binding/prefer-single-binding.test.ts
new file mode 100644
index 0000000..a8dff05
--- /dev/null
+++ b/src/rules/prefer-single-binding/prefer-single-binding.test.ts
@@ -0,0 +1,386 @@
+import { RuleTester } from "@typescript-eslint/rule-tester"
+
+import rule from "./prefer-single-binding"
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parserOptions: {
+ ecmaVersion: 2020,
+ sourceType: "module",
+ ecmaFeatures: { jsx: true },
+ },
+ },
+})
+
+ruleTester.run("effector/prefer-single-binding", rule, {
+ valid: [
+ // With enforceStoresAndEventsSeparation - already separated
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [store] = useUnit([$store]);
+ const [event] = useUnit([event]);
+ return null;
+ };
+ `,
+ options: [{ enforceStoresAndEventsSeparation: true }],
+ },
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isFormSent] = useUnit([HelpFormModel.$isFormSent]);
+ const [sent, submit] = useUnit([HelpFormModel.sentFormChanged, HelpFormModel.submitHelpForm]);
+ return null;
+ };
+ `,
+ options: [{ allowSeparateStoresAndEvents: true }],
+ },
+ // Once useUnit call - OK
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [store, event] = useUnit([$store, event]);
+
+ return null;
+ };
+ `,
+ },
+ // Once useUnit with object-shape - OK
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const { store, event } = useUnit({ store: $store, event: event });
+
+ return null;
+ };
+ `,
+ },
+ // useUnit outside of components - dont check
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const store = useUnit([$store]);
+ const event = useUnit([event]);
+ `,
+ },
+ ],
+
+ invalid: [
+ // Two useUnit calls with array-shape
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [store] = useUnit([$store]);
+ const [event] = useUnit([event]);
+
+ return null;
+ };
+ `,
+ errors: [
+ {
+ messageId: "multipleUseUnit",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [store, event] = useUnit([$store, event]);
+
+ return null;
+ };
+ `,
+ },
+ // Three useUnit with array-shape
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [store] = useUnit([$store]);
+ const [event] = useUnit([event]);
+ const [another] = useUnit([$another]);
+
+ return null;
+ };
+ `,
+ errors: [
+ {
+ messageId: "multipleUseUnit",
+ },
+ {
+ messageId: "multipleUseUnit",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [store, event, another] = useUnit([$store, event, $another]);
+
+ return null;
+ };
+ `,
+ },
+ // Two useUnit calls with object-shape
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const { store } = useUnit({ store: $store });
+ const { event } = useUnit({ event: event });
+
+ return null;
+ };
+ `,
+ errors: [
+ {
+ messageId: "multipleUseUnit",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const { store, event } = useUnit({ store: $store, event: event });
+
+ return null;
+ };
+ `,
+ },
+ // Multiple useUnit calls
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [store1, store2] = useUnit([$store1, $store2]);
+ const [event1, event2] = useUnit([event1, event2]);
+
+ return null;
+ };
+ `,
+ errors: [
+ {
+ messageId: "multipleUseUnit",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [store1, store2, event1, event2] = useUnit([$store1, $store2, event1, event2]);
+
+ return null;
+ };
+ `,
+ },
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isFormSent] = useUnit([HelpFormModel.$isFormSent]);
+ const [sent] = useUnit([HelpFormModel.sentFormChanged]);
+ const [submit] = useUnit([HelpFormModel.submitHelpForm]);
+ return null;
+ };
+ `,
+ options: [{ allowSeparateStoresAndEvents: true }],
+ errors: [
+ {
+ messageId: "multipleUseUnit",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isFormSent] = useUnit([HelpFormModel.$isFormSent]);
+ const [sent, submit] = useUnit([HelpFormModel.sentFormChanged, HelpFormModel.submitHelpForm]);
+ return null;
+ };
+ `,
+ },
+ // MemberExpression - два стора должны объединиться
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isFormSent] = useUnit([HelpFormModel.$isFormSent]);
+ const [isLoading] = useUnit([HelpFormModel.$isLoading]);
+ const [submit] = useUnit([HelpFormModel.submitHelpForm]);
+ return null;
+ };
+ `,
+ options: [{ allowSeparateStoresAndEvents: true }],
+ errors: [
+ {
+ messageId: "multipleUseUnit",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isFormSent, isLoading] = useUnit([HelpFormModel.$isFormSent, HelpFormModel.$isLoading]);
+ const [submit] = useUnit([HelpFormModel.submitHelpForm]);
+ return null;
+ };
+ `,
+ },
+ // Смешанные паттерны именования
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isVisible] = useUnit([Model.isVisible]);
+ const [hasError] = useUnit([Model.hasError]);
+ const [onClick] = useUnit([Model.onClick]);
+ const [handleSubmit] = useUnit([Model.handleSubmit]);
+ return null;
+ };
+ `,
+ options: [{ allowSeparateStoresAndEvents: true }],
+ errors: [
+ {
+ messageId: "multipleUseUnit",
+ },
+ {
+ messageId: "multipleUseUnit",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isVisible, hasError] = useUnit([Model.isVisible, Model.hasError]);
+ const [onClick, handleSubmit] = useUnit([Model.onClick, Model.handleSubmit]);
+ return null;
+ };
+ `,
+ },
+ // Без опции - все должно объединиться
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isFormSent] = useUnit([HelpFormModel.$isFormSent]);
+ const [sent] = useUnit([HelpFormModel.sentFormChanged]);
+ const [submit] = useUnit([HelpFormModel.submitHelpForm]);
+ return null;
+ };
+ `,
+ errors: [
+ {
+ messageId: "multipleUseUnit",
+ },
+ {
+ messageId: "multipleUseUnit",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isFormSent, sent, submit] = useUnit([HelpFormModel.$isFormSent, HelpFormModel.sentFormChanged, HelpFormModel.submitHelpForm]);
+ return null;
+ };
+ `,
+ },
+ // enforceStoresAndEventsSeparation - mixed array
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [value, setValue] = useUnit([$store, event]);
+ return null;
+ };
+ `,
+ options: [{ enforceStoresAndEventsSeparation: true }],
+ errors: [
+ {
+ messageId: "mixedStoresAndEvents",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [value] = useUnit([$store]);
+ const [setValue] = useUnit([event]);
+ return null;
+ };
+ `,
+ },
+ // enforceStoresAndEventsSeparation - mixed array with multiple items
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [value1, value2, handler1, handler2] = useUnit([$store1, $store2, event1, event2]);
+ return null;
+ };
+ `,
+ options: [{ enforceStoresAndEventsSeparation: true }],
+ errors: [
+ {
+ messageId: "mixedStoresAndEvents",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [value1, value2] = useUnit([$store1, $store2]);
+ const [handler1, handler2] = useUnit([event1, event2]);
+ return null;
+ };
+ `,
+ },
+ // enforceStoresAndEventsSeparation - mixed object
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const { value, setValue } = useUnit({ value: $store, setValue: event });
+ return null;
+ };
+ `,
+ options: [{ enforceStoresAndEventsSeparation: true }],
+ errors: [
+ {
+ messageId: "mixedStoresAndEvents",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const { value } = useUnit({ value: $store });
+ const { setValue } = useUnit({ setValue: event });
+ return null;
+ };
+ `,
+ },
+ // Real example with model
+ {
+ code: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isFormSent, submit, reset] = useUnit([
+ FormModel.$isFormSent,
+ FormModel.submitForm,
+ FormModel.resetForm,
+ ]);
+ return null;
+ };
+ `,
+ options: [{ enforceStoresAndEventsSeparation: true }],
+ errors: [
+ {
+ messageId: "mixedStoresAndEvents",
+ },
+ ],
+ output: `
+ import { useUnit } from "effector-react";
+ const Component = () => {
+ const [isFormSent] = useUnit([FormModel.$isFormSent]);
+ const [submit, reset] = useUnit([FormModel.submitForm, FormModel.resetForm]);
+ return null;
+ };
+ `,
+ },
+ ],
+})
diff --git a/src/rules/prefer-single-binding/prefer-single-binding.ts b/src/rules/prefer-single-binding/prefer-single-binding.ts
new file mode 100644
index 0000000..83a1c2b
--- /dev/null
+++ b/src/rules/prefer-single-binding/prefer-single-binding.ts
@@ -0,0 +1,453 @@
+import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils"
+import type { RuleContext, RuleFix, RuleFixer } from "@typescript-eslint/utils/ts-eslint"
+
+import { createRule } from "@/shared/create"
+
+type MessageIds = "multipleUseUnit" | "mixedStoresAndEvents"
+type Options = [{ allowSeparateStoresAndEvents?: boolean; enforceStoresAndEventsSeparation?: boolean }?]
+type Context = RuleContext
+type Fixer = RuleFixer
+
+type UseUnitCall = {
+ statement: TSESTree.VariableDeclaration
+ declarator: TSESTree.VariableDeclarator
+ init: TSESTree.CallExpression
+ id: TSESTree.VariableDeclarator["id"]
+}
+
+type ElementInfo = {
+ element: TSESTree.Expression
+ type: string
+ index: number
+}
+
+type PropertyInfo = {
+ property: TSESTree.Property
+ type: string
+ index: number
+}
+
+type MixedTypes = {
+ stores: ElementInfo[] | PropertyInfo[]
+ events: ElementInfo[] | PropertyInfo[]
+ allTypes: (ElementInfo | PropertyInfo)[]
+ isObject?: boolean
+}
+
+export default createRule({
+ name: "prefer-single-binding",
+ meta: {
+ type: "suggestion",
+ docs: {
+ description: "Recommend using a single useUnit call instead of multiple",
+ },
+ messages: {
+ multipleUseUnit:
+ "Multiple useUnit calls detected. Consider combining them into a single call for better performance.",
+ mixedStoresAndEvents:
+ "useUnit call contains both stores and events. Consider separating them into different calls.",
+ },
+ schema: [
+ {
+ type: "object",
+ properties: {
+ allowSeparateStoresAndEvents: {
+ type: "boolean",
+ default: false,
+ },
+ enforceStoresAndEventsSeparation: {
+ type: "boolean",
+ default: false,
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ fixable: "code",
+ },
+ defaultOptions: [],
+ create: (context) => {
+ const options = context.options[0] ?? {}
+ const allowSeparateStoresAndEvents = options.allowSeparateStoresAndEvents ?? false
+ const enforceStoresAndEventsSeparation = options.enforceStoresAndEventsSeparation ?? false
+
+ return {
+ "FunctionDeclaration, FunctionExpression, ArrowFunctionExpression"(
+ node: TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression,
+ ) {
+ const body = node.body.type === AST_NODE_TYPES.BlockStatement ? node.body.body : null
+ if (!body) return
+
+ const useUnitCalls: UseUnitCall[] = []
+
+ body.forEach((statement) => {
+ if (statement.type === AST_NODE_TYPES.VariableDeclaration && statement.declarations.length > 0) {
+ statement.declarations.forEach((declarator) => {
+ if (
+ declarator.init &&
+ declarator.init.type === AST_NODE_TYPES.CallExpression &&
+ declarator.init.callee.type === AST_NODE_TYPES.Identifier &&
+ declarator.init.callee.name === "useUnit"
+ ) {
+ useUnitCalls.push({
+ statement,
+ declarator,
+ init: declarator.init,
+ id: declarator.id,
+ })
+ }
+ })
+ }
+ })
+
+ if (enforceStoresAndEventsSeparation) {
+ useUnitCalls.forEach((call) => {
+ const mixedTypes = checkMixedTypes(call)
+ if (mixedTypes) {
+ context.report({
+ node: call.init,
+ messageId: "mixedStoresAndEvents",
+ fix(fixer) {
+ return generateSeparationFix(fixer, call, mixedTypes, context)
+ },
+ })
+ }
+ })
+ return
+ }
+
+ if (useUnitCalls.length > 1) {
+ if (allowSeparateStoresAndEvents) {
+ const groups = groupByType(useUnitCalls)
+ if (groups.stores.length > 1) reportMultipleCalls(context, groups.stores)
+ if (groups.events.length > 1) reportMultipleCalls(context, groups.events)
+ if (groups.unknown.length > 1) reportMultipleCalls(context, groups.unknown)
+ } else {
+ useUnitCalls.forEach((call, index) => {
+ if (index > 0) {
+ context.report({
+ node: call.init,
+ messageId: "multipleUseUnit",
+ fix(fixer) {
+ return generateFix(fixer, useUnitCalls, context)
+ },
+ })
+ }
+ })
+ }
+ }
+ },
+ }
+ },
+})
+
+function checkMixedTypes(call: UseUnitCall): MixedTypes | null {
+ const argument = call.init.arguments[0]
+ if (!argument || argument.type === AST_NODE_TYPES.SpreadElement) return null
+
+ if (argument.type === AST_NODE_TYPES.ArrayExpression) {
+ const elements = argument.elements.filter(
+ (el): el is TSESTree.Expression => el !== null && el.type !== AST_NODE_TYPES.SpreadElement,
+ )
+ if (elements.length === 0) return null
+
+ const types: ElementInfo[] = elements.map((element, index) => ({
+ element,
+ type: getElementType(element),
+ index,
+ }))
+
+ const stores = types.filter((t) => t.type === "store")
+ const events = types.filter((t) => t.type === "event")
+
+ if (stores.length > 0 && events.length > 0) {
+ return { stores, events, allTypes: types }
+ }
+ }
+
+ if (argument.type === AST_NODE_TYPES.ObjectExpression) {
+ const properties = argument.properties.filter(
+ (prop): prop is TSESTree.Property => prop.type === AST_NODE_TYPES.Property,
+ )
+ if (properties.length === 0) return null
+
+ const types: PropertyInfo[] = properties.map((prop, index) => ({
+ property: prop,
+ type: getElementType(prop.value as TSESTree.Expression),
+ index,
+ }))
+
+ const stores = types.filter((t) => t.type === "store")
+ const events = types.filter((t) => t.type === "event")
+
+ if (stores.length > 0 && events.length > 0) {
+ return { stores, events, allTypes: types, isObject: true }
+ }
+ }
+
+ return null
+}
+
+function generateSeparationFix(fixer: Fixer, call: UseUnitCall, mixedTypes: MixedTypes, context: Context): RuleFix[] {
+ const sourceCode = context.sourceCode
+
+ const { stores, events, isObject } = mixedTypes
+ const fixes: RuleFix[] = []
+
+ if (isObject) {
+ const storeInfos = stores as PropertyInfo[]
+ const eventInfos = events as PropertyInfo[]
+
+ const storeProps = storeInfos.map((s) => sourceCode.getText(s.property))
+ const eventProps = eventInfos.map((e) => sourceCode.getText(e.property))
+
+ const storeKeys = storeInfos.map((s) => {
+ const key = s.property.key
+ return key.type === AST_NODE_TYPES.Identifier ? key.name : sourceCode.getText(key)
+ })
+
+ const eventKeys = eventInfos.map((e) => {
+ const key = e.property.key
+ return key.type === AST_NODE_TYPES.Identifier ? key.name : sourceCode.getText(key)
+ })
+
+ const statementRange = call.statement.range
+ if (!statementRange) return []
+ const lineStart = sourceCode.text.lastIndexOf("\n", statementRange[0] - 1) + 1
+ const indent = sourceCode.text.slice(lineStart, statementRange[0])
+
+ const storesCode = `const { ${storeKeys.join(", ")} } = useUnit({ ${storeProps.join(", ")} });`
+ const eventsCode = `const { ${eventKeys.join(", ")} } = useUnit({ ${eventProps.join(", ")} });`
+
+ fixes.push(fixer.replaceText(call.statement, `${storesCode}\n${indent}${eventsCode}`))
+ } else {
+ const storeInfos = stores as ElementInfo[]
+ const eventInfos = events as ElementInfo[]
+
+ const storeElements = storeInfos.map((s) => sourceCode.getText(s.element))
+ const eventElements = eventInfos.map((e) => sourceCode.getText(e.element))
+
+ const destructured = call.id.type === AST_NODE_TYPES.ArrayPattern ? call.id.elements : []
+
+ const storeNames = storeInfos
+ .map((s) => {
+ const el = destructured[s.index]
+ return el ? sourceCode.getText(el) : null
+ })
+ .filter((x): x is string => x !== null)
+
+ const eventNames = eventInfos
+ .map((e) => {
+ const el = destructured[e.index]
+ return el ? sourceCode.getText(el) : null
+ })
+ .filter((x): x is string => x !== null)
+
+ const statementRange = call.statement.range
+ if (!statementRange) return []
+ const lineStart = sourceCode.text.lastIndexOf("\n", statementRange[0] - 1) + 1
+ const indent = sourceCode.text.slice(lineStart, statementRange[0])
+
+ const storesCode = `const [${storeNames.join(", ")}] = useUnit([${storeElements.join(", ")}]);`
+ const eventsCode = `const [${eventNames.join(", ")}] = useUnit([${eventElements.join(", ")}]);`
+
+ fixes.push(fixer.replaceText(call.statement, `${storesCode}\n${indent}${eventsCode}`))
+ }
+
+ return fixes
+}
+
+function getElementType(element: TSESTree.Node | null | undefined): string {
+ if (!element) return "unknown"
+
+ if (element.type === AST_NODE_TYPES.Identifier) {
+ return element.name.startsWith("$") ? "store" : "event"
+ }
+
+ if (element.type === AST_NODE_TYPES.MemberExpression) {
+ const property = element.property
+ if (property.type === AST_NODE_TYPES.Identifier) {
+ const name = property.name
+
+ if (name.startsWith("$")) return "store"
+
+ const eventPatterns = [
+ /Event$/i,
+ /Changed$/i,
+ /Triggered$/i,
+ /Clicked$/i,
+ /Pressed$/i,
+ /^on[A-Z]/,
+ /^handle[A-Z]/,
+ /^set[A-Z]/,
+ /^update[A-Z]/,
+ /^submit[A-Z]/,
+ ]
+
+ const storePatterns = [/^is[A-Z]/, /^has[A-Z]/, /Store$/i, /State$/i, /^data$/i, /^value$/i, /^items$/i]
+
+ if (eventPatterns.some((p) => p.test(name))) return "event"
+ if (storePatterns.some((p) => p.test(name))) return "store"
+
+ return "event"
+ }
+ }
+
+ return "unknown"
+}
+
+function getUnitType(call: UseUnitCall): string {
+ const argument = call.init.arguments[0]
+ if (!argument || argument.type === AST_NODE_TYPES.SpreadElement) return "unknown"
+
+ const scoreTypes = (elements: string[]): string => {
+ const storeCount = elements.filter((t) => t === "store").length
+ const eventCount = elements.filter((t) => t === "event").length
+ const unknownCount = elements.filter((t) => t === "unknown").length
+
+ if (storeCount === elements.length) return "store"
+ if (eventCount === elements.length) return "event"
+ if (unknownCount === elements.length) return "unknown"
+ if (storeCount > eventCount && storeCount > unknownCount) return "store"
+ if (eventCount > storeCount && eventCount > unknownCount) return "event"
+ return "unknown"
+ }
+
+ if (argument.type === AST_NODE_TYPES.ArrayExpression) {
+ const elements = argument.elements.filter(
+ (el): el is TSESTree.Expression => el !== null && el.type !== AST_NODE_TYPES.SpreadElement,
+ )
+ if (elements.length === 0) return "unknown"
+ return scoreTypes(elements.map((el) => getElementType(el)))
+ }
+
+ if (argument.type === AST_NODE_TYPES.ObjectExpression) {
+ const properties = argument.properties.filter(
+ (prop): prop is TSESTree.Property => prop.type === AST_NODE_TYPES.Property,
+ )
+ if (properties.length === 0) return "unknown"
+ return scoreTypes(properties.map((prop) => getElementType(prop.value as TSESTree.Expression)))
+ }
+
+ return "unknown"
+}
+
+function groupByType(useUnitCalls: UseUnitCall[]) {
+ const stores: UseUnitCall[] = []
+ const events: UseUnitCall[] = []
+ const unknown: UseUnitCall[] = []
+
+ useUnitCalls.forEach((call) => {
+ const type = getUnitType(call)
+ if (type === "store") stores.push(call)
+ else if (type === "event") events.push(call)
+ else unknown.push(call)
+ })
+
+ return { stores, events, unknown }
+}
+
+function reportMultipleCalls(context: Context, calls: UseUnitCall[]) {
+ calls.forEach((call, index) => {
+ if (index > 0) {
+ context.report({
+ node: call.init,
+ messageId: "multipleUseUnit",
+ fix(fixer) {
+ return generateFix(fixer, calls, context)
+ },
+ })
+ }
+ })
+}
+
+function generateFix(fixer: Fixer, useUnitCalls: UseUnitCall[], context: Context): RuleFix[] | null {
+ const sourceCode = context.sourceCode
+
+ const firstArg = useUnitCalls[0]?.init.arguments[0]
+ if (!firstArg || firstArg.type === AST_NODE_TYPES.SpreadElement) return null
+
+ const isArrayForm = firstArg.type === AST_NODE_TYPES.ArrayExpression
+ const isObjectForm = firstArg.type === AST_NODE_TYPES.ObjectExpression
+
+ const allSameForm = useUnitCalls.every((call) => {
+ const arg = call.init.arguments[0]
+ if (!arg || arg.type === AST_NODE_TYPES.SpreadElement) return false
+ if (isArrayForm) return arg.type === AST_NODE_TYPES.ArrayExpression
+ if (isObjectForm) return arg.type === AST_NODE_TYPES.ObjectExpression
+ return false
+ })
+
+ if (!allSameForm) return null
+
+ const fixes: RuleFix[] = []
+
+ const removeStatement = (statement?: TSESTree.VariableDeclaration) => {
+ const range = statement?.range
+ if (!range) return
+
+ let startIndex = range[0]
+ const lineStart = sourceCode.text.lastIndexOf("\n", startIndex - 1) + 1
+ const textBefore = sourceCode.text.slice(lineStart, startIndex)
+
+ if (/^\s*$/.test(textBefore)) startIndex = lineStart
+
+ const endIndex = range[1]
+ const nextChar = sourceCode.text[endIndex]
+ const removeEnd = nextChar === "\n" || nextChar === "\r" ? endIndex + 1 : endIndex
+
+ fixes.push(fixer.removeRange([startIndex, removeEnd]))
+ }
+
+ if (isArrayForm) {
+ const allElements: string[] = []
+ const allDestructured: string[] = []
+
+ useUnitCalls.forEach((call) => {
+ const arg = call.init.arguments[0]
+ if (arg && arg.type === AST_NODE_TYPES.ArrayExpression) {
+ arg.elements.forEach((el) => {
+ if (el) allElements.push(sourceCode.getText(el))
+ })
+ }
+
+ if (call.id.type === AST_NODE_TYPES.ArrayPattern) {
+ call.id.elements.forEach((el) => {
+ if (el) allDestructured.push(sourceCode.getText(el))
+ })
+ }
+ })
+
+ const combinedCode = `const [${allDestructured.join(", ")}] = useUnit([${allElements.join(", ")}]);`
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+ useUnitCalls[0]?.statement && fixes.push(fixer.replaceText(useUnitCalls[0]?.statement, combinedCode))
+
+ for (let i = 1; i < useUnitCalls.length; i++) {
+ removeStatement(useUnitCalls[i]?.statement)
+ }
+ } else if (isObjectForm) {
+ const allProperties: string[] = []
+ const allDestructuredProps: string[] = []
+
+ useUnitCalls.forEach((call) => {
+ const arg = call.init.arguments[0]
+ if (arg && arg.type === AST_NODE_TYPES.ObjectExpression) {
+ arg.properties.forEach((prop) => allProperties.push(sourceCode.getText(prop)))
+ }
+
+ if (call.id.type === AST_NODE_TYPES.ObjectPattern) {
+ call.id.properties.forEach((prop) => allDestructuredProps.push(sourceCode.getText(prop)))
+ }
+ })
+
+ const combinedCode = `const { ${allDestructuredProps.join(", ")} } = useUnit({ ${allProperties.join(", ")} });`
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+ useUnitCalls[0]?.statement && fixes.push(fixer.replaceText(useUnitCalls[0]?.statement, combinedCode))
+
+ for (let i = 1; i < useUnitCalls.length; i++) {
+ removeStatement(useUnitCalls[i]?.statement)
+ }
+ }
+
+ return fixes
+}
diff --git a/src/ruleset.ts b/src/ruleset.ts
index b11fbd2..9ac9888 100644
--- a/src/ruleset.ts
+++ b/src/ruleset.ts
@@ -13,6 +13,7 @@ const recommended = {
"effector/no-unnecessary-duplication": "warn",
"effector/no-useless-methods": "error",
"effector/no-watch": "warn",
+ "effector/prefer-single-binding": "warn",
} satisfies TSESLint.Linter.RulesRecord
const patronum = {