diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000..e641109
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ "lastFilter": {
+ "state": "OPEN",
+ "assignee": "Olovyannikov"
+ }
+}
+ {
+ "selectedUrlAndAccountId": {
+ "url": "git@github.com:Olovyannikov/eslint-plugin.git",
+ "accountId": "576c952f-7d03-4613-af04-3b4bd892ceab"
+ }
+}
+ {
+ "associatedIndex": 5
+}
+
+
+
+
+
+
+
+
+ {
+ "keyToString": {
+ "ModuleVcsDetector.initialDetectionPerformed": "true",
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.git.unshallow": "true",
+ "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
+ "git-widget-placeholder": "new/prefer-single-binding",
+ "junie.onboarding.icon.badge.shown": "true",
+ "last_opened_file_path": "/Users/ilaolovannikov/WebstormProjects/eslint-plugin-fork/docs",
+ "node.js.detected.package.eslint": "true",
+ "node.js.detected.package.tslint": "true",
+ "node.js.selected.package.eslint": "(autodetect)",
+ "node.js.selected.package.tslint": "(autodetect)",
+ "nodejs_package_manager_path": "pnpm",
+ "npm.docs:build.executor": "Run",
+ "npm.docs:prepare.executor": "Run",
+ "npm.test.executor": "Run",
+ "to.speed.mode.migration.done": "true",
+ "vue.rearranger.settings.migration": "true"
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1765274057645
+
+
+ 1765274057645
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/config/recommended.js b/config/recommended.js
index 8f2f4a8..4653173 100644
--- a/config/recommended.js
+++ b/config/recommended.js
@@ -11,5 +11,6 @@ module.exports = {
"effector/no-unnecessary-combination": "warn",
"effector/no-duplicate-on": "error",
"effector/keep-options-order": "warn",
+ "effector/prefer-single-binding": "warn",
},
};
diff --git a/docs/rules/prefer-single-binding.md b/docs/rules/prefer-single-binding.md
new file mode 100644
index 0000000..8416b30
--- /dev/null
+++ b/docs/rules/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
+module.exports = {
+ 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
+module.exports = {
+ 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
+module.exports = {
+ 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
+module.exports = {
+ rules: {
+ 'effector/prefer-single-binding': 'warn'
+ }
+};
+```
+
+### Allow stores/events separation
+```javascript
+// .eslintrc.js
+module.exports = {
+ rules: {
+ 'effector/prefer-single-binding': ['warn', {
+ allowSeparateStoresAndEvents: true
+ }]
+ }
+};
+```
+
+### Enforce stores/events separation
+```javascript
+// .eslintrc.js
+module.exports = {
+ rules: {
+ 'effector/prefer-single-binding': ['warn', {
+ enforceStoresAndEventsSeparation: true
+ }]
+ }
+};
+```
+
+### Enforce separation and combine duplicates
+```javascript
+// .eslintrc.js
+module.exports = {
+ 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/index.js b/index.js
index 66b7952..1184b8b 100644
--- a/index.js
+++ b/index.js
@@ -20,6 +20,7 @@ module.exports = {
"prefer-useUnit": require("./rules/prefer-useUnit/prefer-useUnit"),
"require-pickup-in-persist": require("./rules/require-pickup-in-persist/require-pickup-in-persist"),
"no-patronum-debug": require("./rules/no-patronum-debug/no-patronum-debug"),
+ "prefer-single-binding": require("./rules/prefer-single-binding/prefer-single-binding"),
},
configs: {
recommended: require("./config/recommended"),
diff --git a/rules/prefer-single-binding/prefer-single-binding.js b/rules/prefer-single-binding/prefer-single-binding.js
new file mode 100644
index 0000000..8d8824a
--- /dev/null
+++ b/rules/prefer-single-binding/prefer-single-binding.js
@@ -0,0 +1,527 @@
+const {createLinkToRule} = require("../../utils/create-link-to-rule");
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'Recommend using a single useUnit call instead of multiple',
+ category: 'Best Practices',
+ recommended: true,
+ url: createLinkToRule('prefer-single-binding'),
+ },
+ 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',
+ },
+ create(context) {
+ const options = context.options[0] || {};
+ const allowSeparateStoresAndEvents = options.allowSeparateStoresAndEvents || false;
+ const enforceStoresAndEventsSeparation = options.enforceStoresAndEventsSeparation || false;
+
+ return {
+ 'FunctionDeclaration, FunctionExpression, ArrowFunctionExpression'(node) {
+ const body = node.body.type === 'BlockStatement' ? node.body.body : null;
+ if (!body) return;
+
+ const useUnitCalls = [];
+
+ // Find all useUnit calls in the function body
+ body.forEach((statement) => {
+ if (
+ statement.type === 'VariableDeclaration' &&
+ statement.declarations.length > 0
+ ) {
+ statement.declarations.forEach((declarator) => {
+ if (
+ declarator.init &&
+ declarator.init.type === 'CallExpression' &&
+ declarator.init.callee.type === 'Identifier' &&
+ declarator.init.callee.name === 'useUnit'
+ ) {
+ useUnitCalls.push({
+ statement,
+ declarator,
+ init: declarator.init,
+ id: declarator.id,
+ });
+ }
+ });
+ }
+ });
+
+ // Check if enforceStoresAndEventsSeparation is enabled
+ 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);
+ },
+ });
+ }
+ });
+
+ // When enforceStoresAndEventsSeparation is true, don't check for multiple calls
+ // because we want separate calls for stores and events
+ return;
+ }
+
+ // If more than one useUnit call is found
+ if (useUnitCalls.length > 1) {
+ // If the option to separate stores and events is enabled
+ if (allowSeparateStoresAndEvents) {
+ const groups = groupByType(useUnitCalls);
+
+ // Check stores group
+ if (groups.stores.length > 1) {
+ reportMultipleCalls(context, groups.stores);
+ }
+
+ // Check events group
+ if (groups.events.length > 1) {
+ reportMultipleCalls(context, groups.events);
+ }
+
+ // Check unknown group (also merge if more than one)
+ if (groups.unknown.length > 1) {
+ reportMultipleCalls(context, groups.unknown);
+ }
+ } else {
+ // Default behavior - all useUnit calls should be combined
+ useUnitCalls.forEach((call, index) => {
+ if (index > 0) {
+ context.report({
+ node: call.init,
+ messageId: 'multipleUseUnit',
+ fix(fixer) {
+ return generateFix(fixer, useUnitCalls, context);
+ },
+ });
+ }
+ });
+ }
+ }
+ },
+ };
+ },
+};
+
+// Check if a single useUnit call contains mixed types (stores and events)
+function checkMixedTypes(call) {
+ const argument = call.init.arguments[0];
+
+ if (!argument) return null;
+
+ // Only check array form
+ if (argument.type === 'ArrayExpression') {
+ const elements = argument.elements.filter(el => el !== null);
+ if (elements.length === 0) return null;
+
+ const types = 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 we have both stores and events, return the separation data
+ if (stores.length > 0 && events.length > 0) {
+ return { stores, events, allTypes: types };
+ }
+ }
+
+ // Only check object form
+ if (argument.type === 'ObjectExpression') {
+ const properties = argument.properties.filter(prop => prop.type === 'Property');
+ if (properties.length === 0) return null;
+
+ const types = properties.map((prop, index) => ({
+ property: prop,
+ type: prop.value ? getElementType(prop.value) : 'unknown',
+ index,
+ }));
+
+ const stores = types.filter(t => t.type === 'store');
+ const events = types.filter(t => t.type === 'event');
+
+ // If we have both stores and events, return the separation data
+ if (stores.length > 0 && events.length > 0) {
+ return { stores, events, allTypes: types, isObject: true };
+ }
+ }
+
+ return null;
+}
+
+// Generate fix to separate mixed stores and events into different useUnit calls
+function generateSeparationFix(fixer, call, mixedTypes, context) {
+ const sourceCode = context.getSourceCode ? context.getSourceCode() : context.sourceCode;
+
+ const { stores, events, isObject } = mixedTypes;
+
+ const fixes = [];
+
+ if (isObject) {
+ // Object form
+ const storeProps = stores.map(s => sourceCode.getText(s.property));
+ const eventProps = events.map(e => sourceCode.getText(e.property));
+
+ const storeKeys = stores.map(s => {
+ const prop = s.property;
+ if (prop.key && prop.key.type === 'Identifier') {
+ return prop.key.name;
+ }
+ return sourceCode.getText(prop.key);
+ });
+
+ const eventKeys = events.map(e => {
+ const prop = e.property;
+ if (prop.key && prop.key.type === 'Identifier') {
+ return prop.key.name;
+ }
+ return sourceCode.getText(prop.key);
+ });
+
+ // Get indentation
+ const statementStart = call.statement.range[0];
+ const lineStart = sourceCode.text.lastIndexOf('\n', statementStart - 1) + 1;
+ const indent = sourceCode.text.slice(lineStart, statementStart);
+
+ const storesCode = `const { ${storeKeys.join(', ')} } = useUnit({ ${storeProps.join(', ')} });`;
+ const eventsCode = `const { ${eventKeys.join(', ')} } = useUnit({ ${eventProps.join(', ')} });`;
+
+ const combinedCode = `${storesCode}\n${indent}${eventsCode}`;
+
+ fixes.push(fixer.replaceText(call.statement, combinedCode));
+ } else {
+ // Array form
+ const storeElements = stores.map(s => sourceCode.getText(s.element));
+ const eventElements = events.map(e => sourceCode.getText(e.element));
+
+ // Get destructured names
+ const destructured = call.id.type === 'ArrayPattern' ? call.id.elements : [];
+ const storeNames = stores.map(s => {
+ const el = destructured[s.index];
+ return el ? sourceCode.getText(el) : null;
+ }).filter(Boolean);
+
+ const eventNames = events.map(e => {
+ const el = destructured[e.index];
+ return el ? sourceCode.getText(el) : null;
+ }).filter(Boolean);
+
+ // Get indentation
+ const statementStart = call.statement.range[0];
+ const lineStart = sourceCode.text.lastIndexOf('\n', statementStart - 1) + 1;
+ const indent = sourceCode.text.slice(lineStart, statementStart);
+
+ const storesCode = `const [${storeNames.join(', ')}] = useUnit([${storeElements.join(', ')}]);`;
+ const eventsCode = `const [${eventNames.join(', ')}] = useUnit([${eventElements.join(', ')}]);`;
+
+ const combinedCode = `${storesCode}\n${indent}${eventsCode}`;
+
+ fixes.push(fixer.replaceText(call.statement, combinedCode));
+ }
+
+ return fixes;
+}
+
+// Determine the type of a single element
+function getElementType(element) {
+ if (!element) return 'unknown';
+
+ // Simple identifier: $store or event
+ if (element.type === 'Identifier') {
+ const name = element.name;
+ return name.startsWith('$') ? 'store' : 'event';
+ }
+
+ // MemberExpression: Model.$store or Model.event
+ if (element.type === 'MemberExpression') {
+ const property = element.property;
+
+ if (property && property.type === 'Identifier') {
+ const name = property.name;
+
+ // Check by property name
+ if (name.startsWith('$')) return 'store';
+
+ // Heuristics to determine type by name
+ const eventPatterns = [
+ /Event$/i, // submitEvent, clickEvent
+ /Changed$/i, // valueChanged, statusChanged
+ /Triggered$/i, // actionTriggered
+ /Clicked$/i, // buttonClicked
+ /Pressed$/i, // keyPressed
+ /^on[A-Z]/, // onClick, onSubmit
+ /^handle[A-Z]/, // handleClick, handleSubmit
+ /^set[A-Z]/, // setValue, setData
+ /^update[A-Z]/, // updateValue, updateData
+ /^submit[A-Z]/, // submitForm, submitData
+ ];
+
+ const storePatterns = [
+ /^is[A-Z]/, // isLoading, isVisible
+ /^has[A-Z]/, // hasError, hasData
+ /Store$/i, // userStore, dataStore
+ /State$/i, // appState, formState
+ /^data$/i, // data
+ /^value$/i, // value
+ /^items$/i, // items
+ ];
+
+ // Check event patterns
+ if (eventPatterns.some(pattern => pattern.test(name))) {
+ return 'event';
+ }
+
+ // Check store patterns
+ if (storePatterns.some(pattern => pattern.test(name))) {
+ return 'store';
+ }
+
+ // Default for MemberExpression without explicit $ - consider it an event
+ // since methods are usually events
+ return 'event';
+ }
+ }
+
+ return 'unknown';
+}
+
+// Determine the unit type (store or event) by variable name
+function getUnitType(call) {
+ const argument = call.init.arguments[0];
+
+ if (!argument) return 'unknown';
+
+ // For arrays, look at elements
+ if (argument.type === 'ArrayExpression') {
+ const elements = argument.elements.filter(el => el !== null);
+ if (elements.length === 0) return 'unknown';
+
+ // Collect types of all elements
+ const types = elements.map(element => getElementType(element));
+
+ // Count each type
+ const storeCount = types.filter(t => t === 'store').length;
+ const eventCount = types.filter(t => t === 'event').length;
+ const unknownCount = types.filter(t => t === 'unknown').length;
+
+ // If all elements are of the same type
+ if (storeCount === types.length) return 'store';
+ if (eventCount === types.length) return 'event';
+ if (unknownCount === types.length) return 'unknown';
+
+ // If there's a mix, return the predominant type
+ if (storeCount > eventCount && storeCount > unknownCount) return 'store';
+ if (eventCount > storeCount && eventCount > unknownCount) return 'event';
+
+ // If unclear, return unknown
+ return 'unknown';
+ }
+
+ // For objects, look at properties
+ if (argument.type === 'ObjectExpression') {
+ const properties = argument.properties.filter(prop => prop.type === 'Property');
+ if (properties.length === 0) return 'unknown';
+
+ // Collect types of all properties
+ const types = properties.map(prop => {
+ if (prop.value) {
+ return getElementType(prop.value);
+ }
+ return 'unknown';
+ });
+
+ // Count each type
+ const storeCount = types.filter(t => t === 'store').length;
+ const eventCount = types.filter(t => t === 'event').length;
+ const unknownCount = types.filter(t => t === 'unknown').length;
+
+ // If all properties are of the same type
+ if (storeCount === types.length) return 'store';
+ if (eventCount === types.length) return 'event';
+ if (unknownCount === types.length) return 'unknown';
+
+ // If there's a mix, return the predominant type
+ if (storeCount > eventCount && storeCount > unknownCount) return 'store';
+ if (eventCount > storeCount && eventCount > unknownCount) return 'event';
+
+ return 'unknown';
+ }
+
+ return 'unknown';
+}
+
+// Group calls by type
+function groupByType(useUnitCalls) {
+ const stores = [];
+ const events = [];
+ const unknown = [];
+
+ 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 };
+}
+
+// Report error for a group of calls
+function reportMultipleCalls(context, calls) {
+ calls.forEach((call, index) => {
+ if (index > 0) {
+ context.report({
+ node: call.init,
+ messageId: 'multipleUseUnit',
+ fix(fixer) {
+ return generateFix(fixer, calls, context);
+ },
+ });
+ }
+ });
+}
+
+function generateFix(fixer, useUnitCalls, context) {
+ const sourceCode = context.getSourceCode ? context.getSourceCode() : context.sourceCode;
+
+ // Determine if object or array form is used
+ const firstArg = useUnitCalls[0].init.arguments[0];
+ const isArrayForm = firstArg && firstArg.type === 'ArrayExpression';
+ const isObjectForm = firstArg && firstArg.type === 'ObjectExpression';
+
+ // If forms are mixed or undefined, don't try to fix automatically
+ const allSameForm = useUnitCalls.every((call) => {
+ const arg = call.init.arguments[0];
+ if (isArrayForm) return arg && arg.type === 'ArrayExpression';
+ if (isObjectForm) return arg && arg.type === 'ObjectExpression';
+ return false;
+ });
+
+ if (!allSameForm) return null;
+
+ const fixes = [];
+
+ if (isArrayForm) {
+ // Collect all array elements
+ const allElements = [];
+ const allDestructured = [];
+
+ useUnitCalls.forEach((call) => {
+ const arg = call.init.arguments[0];
+ if (arg.elements) {
+ allElements.push(...arg.elements.map(el => sourceCode.getText(el)));
+ }
+
+ if (call.id.type === 'ArrayPattern') {
+ allDestructured.push(
+ ...call.id.elements.map(el => el ? sourceCode.getText(el) : null).filter(Boolean)
+ );
+ }
+ });
+
+ // Create combined call
+ const combinedCode = `const [${allDestructured.join(', ')}] = useUnit([${allElements.join(', ')}]);`;
+
+ // Replace first call with combined one
+ fixes.push(fixer.replaceText(useUnitCalls[0].statement, combinedCode));
+
+ // Remove remaining calls
+ for (let i = 1; i < useUnitCalls.length; i++) {
+ const statement = useUnitCalls[i].statement;
+
+ // Find line start (including indentation)
+ let startIndex = statement.range[0];
+ const lineStart = sourceCode.text.lastIndexOf('\n', startIndex - 1) + 1;
+ const textBeforeStatement = sourceCode.text.slice(lineStart, startIndex);
+
+ // If only spaces before statement, remove the entire line
+ if (/^\s*$/.test(textBeforeStatement)) {
+ startIndex = lineStart;
+ }
+
+ // Find end (including newline)
+ const endIndex = statement.range[1];
+ const nextChar = sourceCode.text[endIndex];
+ const removeEnd = (nextChar === '\n' || nextChar === '\r') ? endIndex + 1 : endIndex;
+
+ fixes.push(fixer.removeRange([startIndex, removeEnd]));
+ }
+ } else if (isObjectForm) {
+ // Collect all object properties
+ const allProperties = [];
+ const allDestructuredProps = [];
+
+ useUnitCalls.forEach((call) => {
+ const arg = call.init.arguments[0];
+ if (arg.properties) {
+ allProperties.push(...arg.properties.map(prop => sourceCode.getText(prop)));
+ }
+
+ if (call.id.type === 'ObjectPattern') {
+ allDestructuredProps.push(
+ ...call.id.properties.map(prop => sourceCode.getText(prop))
+ );
+ }
+ });
+
+ // Create combined call
+ const combinedCode = `const { ${allDestructuredProps.join(', ')} } = useUnit({ ${allProperties.join(', ')} });`;
+
+ // Replace first call with combined one
+ fixes.push(fixer.replaceText(useUnitCalls[0].statement, combinedCode));
+
+ // Remove remaining calls
+ for (let i = 1; i < useUnitCalls.length; i++) {
+ const statement = useUnitCalls[i].statement;
+
+ // Find line start (including indentation)
+ let startIndex = statement.range[0];
+ const lineStart = sourceCode.text.lastIndexOf('\n', startIndex - 1) + 1;
+ const textBeforeStatement = sourceCode.text.slice(lineStart, startIndex);
+
+ // If only spaces before statement, remove the entire line
+ if (/^\s*$/.test(textBeforeStatement)) {
+ startIndex = lineStart;
+ }
+
+ // Find end (including newline)
+ const endIndex = statement.range[1];
+ const nextChar = sourceCode.text[endIndex];
+ const removeEnd = (nextChar === '\n' || nextChar === '\r') ? endIndex + 1 : endIndex;
+
+ fixes.push(fixer.removeRange([startIndex, removeEnd]));
+ }
+ }
+
+ return fixes;
+}
\ No newline at end of file
diff --git a/rules/prefer-single-binding/prefer-single-binding.test.js b/rules/prefer-single-binding/prefer-single-binding.test.js
new file mode 100644
index 0000000..70d8e0b
--- /dev/null
+++ b/rules/prefer-single-binding/prefer-single-binding.test.js
@@ -0,0 +1,389 @@
+const { RuleTester } = require("eslint");
+const rule = require("./prefer-single-binding");
+
+const ruleTester = new RuleTester({
+ parserOptions: {
+ ecmaVersion: 2020,
+ sourceType: "module",
+ ecmaFeatures: { jsx: true },
+ },
+});
+
+ruleTester.run("effector/prefer-single-binding.test", 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;
+ };
+ `,
+ },
+ ],
+});
\ No newline at end of file