diff --git a/.changeset/forty-wolves-care.md b/.changeset/forty-wolves-care.md
new file mode 100644
index 0000000..1cc2024
--- /dev/null
+++ b/.changeset/forty-wolves-care.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-effector": minor
+---
+
+New rule `no-units-spawn-in-render`
diff --git a/package.json b/package.json
index 484b986..4ea07c7 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"@typescript-eslint/rule-tester": "^8.52.0",
"@vitest/coverage-v8": "^4.0.16",
"effector": "^23.4.4",
+ "effector-factorio": "^1.3.0",
"effector-react": "^23.3.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index feb2a2f..92eeef4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -45,6 +45,9 @@ importers:
effector:
specifier: ^23.4.4
version: 23.4.4
+ effector-factorio:
+ specifier: ^1.3.0
+ version: 1.3.0(effector@23.4.4)(react@19.2.3)
effector-react:
specifier: ^23.3.0
version: 23.3.0(effector@23.4.4)(react@19.2.3)
@@ -1385,6 +1388,17 @@ packages:
oxc-resolver:
optional: true
+ effector-factorio@1.3.0:
+ resolution: {integrity: sha512-KYz4AoHjyEZlzGmoNJaf1qzZn8zbYs5OQB78HjX8zdGxCwvNTRasA7ETcgmebbu+2l4je6nnf6fnczTB4o45mg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ effector: ^22 || ^23
+ react: '>=16.14.0'
+ solid-js: ~1.4.8
+ peerDependenciesMeta:
+ solid-js:
+ optional: true
+
effector-react@23.3.0:
resolution: {integrity: sha512-QR0+x1EnbiWhO80Yc0GVF+I9xCYoxBm3t+QLB5Wg+1uY1Q1BrSWDmKvJaJJZ/+9BU4RAr25yS5J2EkdWnicu8g==}
engines: {node: '>=11.0.0'}
@@ -3692,6 +3706,11 @@ snapshots:
dts-resolver@2.1.3: {}
+ effector-factorio@1.3.0(effector@23.4.4)(react@19.2.3):
+ dependencies:
+ effector: 23.4.4
+ react: 19.2.3
+
effector-react@23.3.0(effector@23.4.4)(react@19.2.3):
dependencies:
effector: 23.4.4
diff --git a/src/index.ts b/src/index.ts
index bac8f24..cd37dbb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -15,6 +15,7 @@ import noForward from "./rules/no-forward/no-forward"
import noGetState from "./rules/no-getState/no-getState"
import noGuard from "./rules/no-guard/no-guard"
import noPatronumDebug from "./rules/no-patronum-debug/no-patronum-debug"
+import noUnitsSpawnInRender from "./rules/no-units-spawn-in-render/no-units-spawn-in-render"
import noUnnecessaryCombination from "./rules/no-unnecessary-combination/no-unnecessary-combination"
import noUnnecessaryDuplication from "./rules/no-unnecessary-duplication/no-unnecessary-duplication"
import noUselessMethods from "./rules/no-useless-methods/no-useless-methods"
@@ -40,6 +41,7 @@ const base = {
"no-getState": noGetState,
"no-guard": noGuard,
"no-patronum-debug": noPatronumDebug,
+ "no-units-spawn-in-render": noUnitsSpawnInRender,
"no-unnecessary-combination": noUnnecessaryCombination,
"no-unnecessary-duplication": noUnnecessaryDuplication,
"no-useless-methods": noUselessMethods,
diff --git a/src/rules/no-units-spawn-in-render/fixtures/context.tsx b/src/rules/no-units-spawn-in-render/fixtures/context.tsx
new file mode 100644
index 0000000..11da7c1
--- /dev/null
+++ b/src/rules/no-units-spawn-in-render/fixtures/context.tsx
@@ -0,0 +1,6 @@
+import { createStore } from "effector"
+import { createContext } from "react"
+
+const $store = createStore(0)
+
+export const ModelContext = createContext({ $store })
diff --git a/src/rules/no-units-spawn-in-render/fixtures/factorio.ts b/src/rules/no-units-spawn-in-render/fixtures/factorio.ts
new file mode 100644
index 0000000..cdff836
--- /dev/null
+++ b/src/rules/no-units-spawn-in-render/fixtures/factorio.ts
@@ -0,0 +1,12 @@
+import { createEvent, createStore } from "effector"
+import { modelFactory } from "effector-factorio"
+
+export const counterFactory = modelFactory(() => {
+ const $count = createStore(0)
+ const inc = createEvent()
+ const dec = createEvent()
+
+ $count.on(inc, (n) => n + 1).on(dec, (n) => n - 1)
+
+ return { $count, inc, dec }
+})
diff --git a/src/rules/no-units-spawn-in-render/fixtures/factory.ts b/src/rules/no-units-spawn-in-render/fixtures/factory.ts
new file mode 100644
index 0000000..5fe9fa6
--- /dev/null
+++ b/src/rules/no-units-spawn-in-render/fixtures/factory.ts
@@ -0,0 +1,30 @@
+import { createEffect, createEvent, createStore, sample } from "effector"
+
+export function createModel() {
+ const $store = createStore(0)
+ const increment = createEvent()
+ const fetchFx = createEffect(() => {})
+
+ sample({ clock: increment, target: fetchFx })
+
+ return { $store, increment, fetchFx }
+}
+
+export function createCounter(initial: number) {
+ const $count = createStore(initial)
+ const inc = createEvent()
+ const dec = createEvent()
+
+ $count.on(inc, (n) => n + 1).on(dec, (n) => n - 1)
+
+ return { $count, inc, dec }
+}
+
+export function createDeepModel() {
+ const $store = createStore(0)
+ return { level1: { level2: { $store } } }
+}
+
+export function regularFunction() {
+ return { foo: "bar" }
+}
diff --git a/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.md b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.md
new file mode 100644
index 0000000..3e2a214
--- /dev/null
+++ b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.md
@@ -0,0 +1,138 @@
+---
+description: Forbid creating Effector units inside React components or hooks
+---
+
+# effector/no-units-spawn-in-render
+
+Forbids creating Effector units or calling operators inside React components or hooks.
+
+Creating units in render leads to:
+- New units created on every render, causing memory leaks
+- Subscriptions and relationships being recreated unnecessarily
+- Unpredictable application state
+
+## Examples
+
+### Invalid
+
+```tsx
+// 👎 Units created inside component will be recreated on every render
+function Component() {
+ const $store = createStore(0)
+ const clicked = createEvent()
+
+ sample({ clock: clicked, target: $store })
+
+ return
+}
+
+// 👎 Units inside hooks (useMemo, useEffect, useCallback) are still problematic
+function Component() {
+ const $store = useMemo(() => createStore(0), [])
+
+ useEffect(() => {
+ sample({ clock: clicked, target: $store })
+ }, [])
+
+ return
hello
+}
+
+// 👎 Custom factory functions that return units are also forbidden
+function createModel() {
+ const $store = createStore(0)
+ return { $store }
+}
+
+function Component() {
+ const model = createModel()
+ return hello
+}
+
+// 👎 Same rules apply to custom hooks
+function useMyStore() {
+ return useMemo(() => createStore(0), [])
+}
+```
+
+### Valid
+
+```tsx
+// 👍 Units created at module level (outside components)
+const $store = createStore(0)
+const clicked = createEvent()
+
+sample({ clock: clicked, target: $store })
+
+function Component() {
+ const value = useUnit($store)
+ const click = useUnit(clicked)
+
+ return
+}
+
+// 👍 Units accessed via useContext are OK
+const ModelContext = createContext({ $store })
+
+function Component() {
+ const { $store } = useContext(ModelContext)
+ const value = useUnit($store)
+
+ return {value}
+}
+
+// 👍 effector-factorio's useModel() retrieves units from context, not creating new ones
+import { counterFactory } from "./model"
+
+function Counter() {
+ const { $count, inc } = counterFactory.useModel()
+ const count = useUnit($count)
+
+ return
+}
+```
+
+## Rule Details
+
+This rule detects:
+
+1. **Direct Effector API calls**: `createStore`, `createEvent`, `createEffect`, `createDomain`, `createApi`, `restore`
+2. **Effector operators**: `sample`, `guard`, `forward`, `merge`, `split`, `combine`, `attach`
+3. **Custom factory functions**: Functions that return objects containing Effector units (requires TypeScript)
+
+The rule uses TypeScript type information to detect custom factories that return Effector units.
+
+## Configuration
+
+### `detectCustomFactories`
+
+Controls whether the rule detects custom factory functions (functions that return objects containing Effector units). Defaults to `true`.
+
+**Disable custom factory detection entirely:**
+
+```jsonc
+// Only flag direct Effector API calls and operators — ignore custom factories
+"effector/no-units-spawn-in-render": ["error", { "detectCustomFactories": false }]
+```
+
+**Allow specific functions (allowlist):**
+
+```jsonc
+// Detect custom factories, but skip these specific function names
+"effector/no-units-spawn-in-render": ["error", {
+ "detectCustomFactories": { "allowlist": ["useModel", "getViewModel"] }
+}]
+```
+
+The allowlist matches against the resolved callee name — `"getModel"` will skip both `getModel()` and `obj.getModel()`.
+
+## Known Exceptions
+
+### `effector-factorio`
+
+The [`effector-factorio`](https://github.com/Kelin2025/effector-factorio) library provides a `factory.useModel()` hook that retrieves pre-created units from React context (similar to `useContext`). This rule has a built-in exception for `useModel` — it will not be flagged.
+
+Note that `factory.createModel()` **will** still be flagged if called inside a component, since it creates new unit instances.
+
+## When Not To Use It
+
+If you have a specific use case where creating units dynamically is intentional and properly managed (e.g., in a factory pattern with proper cleanup), you may disable this rule for that specific case.
diff --git a/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.test.ts b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.test.ts
new file mode 100644
index 0000000..16302db
--- /dev/null
+++ b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.test.ts
@@ -0,0 +1,611 @@
+import path from "path"
+
+import { RuleTester } from "@typescript-eslint/rule-tester"
+import { parser } from "typescript-eslint"
+
+import { tsx } from "@/shared/tag"
+
+import rule from "./no-units-spawn-in-render"
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parser,
+ parserOptions: {
+ projectService: { allowDefaultProject: ["*.tsx"], defaultProject: "tsconfig.fixture.json" },
+ ecmaFeatures: { jsx: true },
+ },
+ },
+})
+
+const fixture = (file: string) => path.resolve(__dirname, "fixtures", file)
+
+ruleTester.run("no-units-spawn-in-render", rule, {
+ valid: [
+ {
+ name: "units created at module level",
+ code: tsx`
+ import React from "react"
+ import { createStore, createEvent, sample } from "effector"
+
+ const $store = createStore(0)
+ const clicked = createEvent()
+
+ sample({ clock: clicked, target: $store })
+
+ const Component: React.FC = () => {
+ return
+ }
+ `,
+ },
+ {
+ name: "units accessed via useContext",
+ code: tsx`
+ import React, { useContext } from "react"
+ import { useUnit } from "effector-react"
+
+ import { ModelContext } from "${fixture("context")}"
+
+ const Component: React.FC = () => {
+ const { $store } = useContext(ModelContext)
+ const value = useUnit($store)
+
+ return {value}
+ }
+ `,
+ },
+ {
+ name: "units used via useUnit",
+ code: tsx`
+ import React from "react"
+ import { createStore, createEvent } from "effector"
+ import { useUnit } from "effector-react"
+
+ const $store = createStore(0)
+ const clicked = createEvent()
+
+ const Component: React.FC = () => {
+ const [value, click] = useUnit([$store, clicked])
+
+ return
+ }
+ `,
+ },
+ {
+ name: "non-React function returning non-JSX",
+ code: tsx`
+ import { createStore, createEvent } from "effector"
+
+ function createModel() {
+ const $store = createStore(0)
+ const clicked = createEvent()
+ return { $store, clicked }
+ }
+
+ const model = createModel()
+ `,
+ },
+ {
+ name: "class component does not trigger",
+ code: tsx`
+ import React from "react"
+ import { createStore } from "effector"
+
+ class Component extends React.Component {
+ store = createStore(0)
+
+ render() {
+ return hello
+ }
+ }
+ `,
+ },
+ {
+ name: "regular function call in component",
+ code: tsx`
+ import React from "react"
+
+ import { regularFunction } from "${fixture("factory")}"
+
+ const Component: React.FC = () => {
+ const data = regularFunction()
+
+ return {data.foo}
+ }
+ `,
+ },
+ {
+ name: "effector-factorio useModel in component (context-based)",
+ code: tsx`
+ import React from "react"
+ import { useUnit } from "effector-react"
+
+ import { counterFactory } from "${fixture("factorio")}"
+
+ const Component: React.FC = () => {
+ const { $count, inc, dec } = counterFactory.useModel()
+ const count = useUnit($count)
+
+ return {count}
+ }
+ `,
+ },
+ {
+ name: "custom factory with detectCustomFactories disabled",
+ options: [{ detectCustomFactories: false }],
+ code: tsx`
+ import React from "react"
+
+ import { createModel } from "${fixture("factory")}"
+
+ const Component: React.FC = () => {
+ const model = createModel()
+
+ return hello
+ }
+ `,
+ },
+ {
+ name: "allowlisted custom factory in component",
+ options: [{ detectCustomFactories: { allowlist: ["createModel"] } }],
+ code: tsx`
+ import React from "react"
+
+ import { createModel } from "${fixture("factory")}"
+
+ const Component: React.FC = () => {
+ const model = createModel()
+
+ return hello
+ }
+ `,
+ },
+ {
+ name: "fork and allSettled in non-render context",
+ code: tsx`
+ import { createStore, fork, allSettled, createEvent } from "effector"
+
+ const $store = createStore(0)
+ const event = createEvent()
+
+ async function runTest() {
+ const scope = fork()
+ await allSettled(event, { scope })
+ }
+ `,
+ },
+ ],
+ invalid: [
+ {
+ name: "createStore directly in component body",
+ code: tsx`
+ import React from "react"
+ import { createStore } from "effector"
+
+ const Component: React.FC = () => {
+ const $store = createStore(0)
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }],
+ },
+ {
+ name: "createEvent directly in component body",
+ code: tsx`
+ import React from "react"
+ import { createEvent } from "effector"
+
+ function Component() {
+ const clicked = createEvent()
+
+ return
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 19, data: { name: "createEvent" } }],
+ },
+ {
+ name: "createEffect directly in component body",
+ code: tsx`
+ import React from "react"
+ import { createEffect } from "effector"
+
+ const Component = () => {
+ const fetchFx = createEffect(() => {})
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 19, data: { name: "createEffect" } }],
+ },
+ {
+ name: "sample operator in component body",
+ code: tsx`
+ import React from "react"
+ import { createStore, createEvent, sample } from "effector"
+
+ const $store = createStore(0)
+ const clicked = createEvent()
+
+ const Component: React.FC = () => {
+ sample({ clock: clicked, target: $store })
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noOperatorInRender", line: 8, column: 3, data: { name: "sample" } }],
+ },
+ {
+ name: "combine operator in component body",
+ code: tsx`
+ import React from "react"
+ import { createStore, combine } from "effector"
+
+ const $a = createStore(1)
+ const $b = createStore(2)
+
+ const Component: React.FC = () => {
+ const $sum = combine($a, $b, (a, b) => a + b)
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noOperatorInRender", line: 8, column: 16, data: { name: "combine" } }],
+ },
+ {
+ name: "createStore inside useMemo",
+ code: tsx`
+ import React, { useMemo } from "react"
+ import { createStore } from "effector"
+
+ const Component: React.FC = () => {
+ const $store = useMemo(() => createStore(0), [])
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 32, data: { name: "createStore" } }],
+ },
+ {
+ name: "sample inside useEffect",
+ code: tsx`
+ import React, { useEffect } from "react"
+ import { createStore, createEvent, sample } from "effector"
+
+ const $store = createStore(0)
+ const clicked = createEvent()
+
+ const Component: React.FC = () => {
+ useEffect(() => {
+ sample({ clock: clicked, target: $store })
+ }, [])
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noOperatorInRender", line: 9, column: 5, data: { name: "sample" } }],
+ },
+ {
+ name: "createStore inside useCallback",
+ code: tsx`
+ import React, { useCallback } from "react"
+ import { createStore } from "effector"
+
+ const Component: React.FC = () => {
+ const init = useCallback(() => {
+ const $store = createStore(0)
+ return $store
+ }, [])
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 6, column: 20, data: { name: "createStore" } }],
+ },
+ {
+ name: "custom factory in component body",
+ code: tsx`
+ import React from "react"
+
+ import { createModel } from "${fixture("factory")}"
+
+ const Component: React.FC = () => {
+ const model = createModel()
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noCustomFactoryInRender", line: 6, column: 17, data: { name: "createModel" } }],
+ },
+ {
+ name: "custom factory inside useMemo",
+ code: tsx`
+ import React, { useMemo } from "react"
+
+ import { createCounter } from "${fixture("factory")}"
+
+ const Component: React.FC = () => {
+ const counter = useMemo(() => createCounter(0), [])
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noCustomFactoryInRender", line: 6, column: 33, data: { name: "createCounter" } }],
+ },
+ {
+ name: "effector factory in custom hook",
+ code: tsx`
+ import { createStore } from "effector"
+
+ function useMyStore() {
+ const $store = createStore(0)
+ return $store
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 4, column: 18, data: { name: "createStore" } }],
+ },
+ {
+ name: "effector factory in custom hook with useMemo",
+ code: tsx`
+ import { useMemo } from "react"
+ import { createStore } from "effector"
+
+ function useMyStore() {
+ return useMemo(() => createStore(0), [])
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 24, data: { name: "createStore" } }],
+ },
+ {
+ name: "custom factory in custom hook",
+ code: tsx`
+ import { useMemo } from "react"
+
+ import { createModel } from "${fixture("factory")}"
+
+ function useModel() {
+ return useMemo(() => createModel(), [])
+ }
+ `,
+ errors: [{ messageId: "noCustomFactoryInRender", line: 6, column: 24, data: { name: "createModel" } }],
+ },
+ {
+ name: "multiple violations in component",
+ code: tsx`
+ import React from "react"
+ import { createStore, createEvent, sample } from "effector"
+
+ const Component: React.FC = () => {
+ const $store = createStore(0)
+ const clicked = createEvent()
+ sample({ clock: clicked, target: $store })
+
+ return
+ }
+ `,
+ errors: [
+ { messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } },
+ { messageId: "noFactoryInRender", line: 6, column: 19, data: { name: "createEvent" } },
+ { messageId: "noOperatorInRender", line: 7, column: 3, data: { name: "sample" } },
+ ],
+ },
+ {
+ name: "attach operator in component",
+ code: tsx`
+ import React from "react"
+ import { createEffect, attach } from "effector"
+
+ const baseFx = createEffect(() => {})
+
+ const Component: React.FC = () => {
+ const boundFx = attach({ effect: baseFx, mapParams: () => undefined })
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noOperatorInRender", line: 7, column: 19, data: { name: "attach" } }],
+ },
+ {
+ name: "merge operator in component",
+ code: tsx`
+ import React from "react"
+ import { createEvent, merge } from "effector"
+
+ const a = createEvent()
+ const b = createEvent()
+
+ const Component: React.FC = () => {
+ const merged = merge([a, b])
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noOperatorInRender", line: 8, column: 18, data: { name: "merge" } }],
+ },
+ {
+ name: "split operator in component",
+ code: tsx`
+ import React from "react"
+ import { createEvent, split } from "effector"
+
+ const event = createEvent()
+
+ const Component: React.FC = () => {
+ const { positive, negative } = split(event, {
+ positive: (n) => n > 0,
+ negative: (n) => n < 0,
+ })
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noOperatorInRender", line: 7, column: 34, data: { name: "split" } }],
+ },
+ {
+ name: "restore in component",
+ code: tsx`
+ import React from "react"
+ import { createEvent, restore } from "effector"
+
+ const setValue = createEvent()
+
+ const Component: React.FC = () => {
+ const $value = restore(setValue, 0)
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 7, column: 18, data: { name: "restore" } }],
+ },
+ {
+ name: "createDomain in component",
+ code: tsx`
+ import React from "react"
+ import { createDomain } from "effector"
+
+ const Component: React.FC = () => {
+ const domain = createDomain()
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createDomain" } }],
+ },
+ {
+ name: "createApi in component",
+ code: tsx`
+ import React from "react"
+ import { createStore, createApi } from "effector"
+
+ const $store = createStore(0)
+
+ const Component: React.FC = () => {
+ const api = createApi($store, {
+ increment: (n) => n + 1,
+ })
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 7, column: 15, data: { name: "createApi" } }],
+ },
+ {
+ name: "inferred component (arrow function returning JSX)",
+ code: tsx`
+ import React from "react"
+ import { createStore } from "effector"
+
+ const Component = () => {
+ const $store = createStore(0)
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }],
+ },
+ {
+ name: "inferred component via forwardRef",
+ code: tsx`
+ import React from "react"
+ import { createStore } from "effector"
+
+ const Component = React.forwardRef((props, ref) => {
+ const $store = createStore(0)
+
+ return hello
+ })
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }],
+ },
+ {
+ name: "inferred component via memo",
+ code: tsx`
+ import { memo } from "react"
+ import { createStore } from "effector"
+
+ const Component = memo(() => {
+ const $store = createStore(0)
+
+ return hello
+ })
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }],
+ },
+ {
+ name: "renamed import in component",
+ code: tsx`
+ import React from "react"
+ import { createStore as cs } from "effector"
+
+ const Component: React.FC = () => {
+ const $store = cs(0)
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "cs" } }],
+ },
+ {
+ name: "deep nested factory (unit 3 levels deep)",
+ code: tsx`
+ import React from "react"
+
+ import { createDeepModel } from "${fixture("factory")}"
+
+ const Component: React.FC = () => {
+ const model = createDeepModel()
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noCustomFactoryInRender", line: 6, column: 17, data: { name: "createDeepModel" } }],
+ },
+ {
+ name: "forward (deprecated) operator in component",
+ code: tsx`
+ import React from "react"
+ import { createStore, createEvent, forward } from "effector"
+
+ const $store = createStore(0)
+ const clicked = createEvent()
+
+ const Component: React.FC = () => {
+ forward({ from: clicked, to: $store })
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noOperatorInRender", line: 8, column: 3, data: { name: "forward" } }],
+ },
+ {
+ name: "direct effector import still flagged with detectCustomFactories disabled",
+ options: [{ detectCustomFactories: false }],
+ code: tsx`
+ import React from "react"
+ import { createStore } from "effector"
+
+ const Component: React.FC = () => {
+ const $store = createStore(0)
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }],
+ },
+ {
+ name: "non-allowlisted custom factory still flagged",
+ options: [{ detectCustomFactories: { allowlist: ["createModel"] } }],
+ code: tsx`
+ import React from "react"
+
+ import { createCounter } from "${fixture("factory")}"
+
+ const Component: React.FC = () => {
+ const counter = createCounter(0)
+
+ return hello
+ }
+ `,
+ errors: [{ messageId: "noCustomFactoryInRender", line: 6, column: 19, data: { name: "createCounter" } }],
+ },
+ ],
+})
diff --git a/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.ts b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.ts
new file mode 100644
index 0000000..8d8925d
--- /dev/null
+++ b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.ts
@@ -0,0 +1,282 @@
+import { getContextualType, typeMatchesSpecifier } from "@typescript-eslint/type-utils"
+import { AST_NODE_TYPES, ESLintUtils, type TSESTree as ESNode } from "@typescript-eslint/utils"
+import { type Program, type Node as TSNode, type Type, type TypeChecker, isExpression } from "typescript"
+
+import { createRule } from "@/shared/create"
+import { isType } from "@/shared/is"
+import { nameOf } from "@/shared/name"
+import { PACKAGE_NAME } from "@/shared/package"
+
+const EFFECTOR_FACTORIES = new Set([
+ "createStore",
+ "createEvent",
+ "createEffect",
+ "createDomain",
+ "createApi",
+ "restore",
+])
+
+const EFFECTOR_OPERATORS = new Set(["sample", "guard", "forward", "merge", "split", "combine", "attach"])
+
+const REACT_HOOKS_SPEC = {
+ from: "package" as const,
+ package: "react",
+ name: [
+ "useState",
+ "useEffect",
+ "useLayoutEffect",
+ "useCallback",
+ "useMemo",
+ "useRef",
+ "useReducer",
+ "useImperativeHandle",
+ "useDebugValue",
+ "useDeferredValue",
+ "useTransition",
+ "useId",
+ "useSyncExternalStore",
+ "useInsertionEffect",
+ "useContext",
+ ],
+}
+
+const EFFECTOR_FACTORY_SPEC = { from: "package" as const, package: "effector", name: [...EFFECTOR_FACTORIES] }
+const EFFECTOR_OPERATOR_SPEC = { from: "package" as const, package: "effector", name: [...EFFECTOR_OPERATORS] }
+
+// effector-factorio's `factory.useModel()` is a context-based hook (like useContext) that retrieves
+// pre-created units — not a factory that spawns new ones. Exclude it from false-positive reports.
+// We identify a factorio factory by the structural shape of the receiver object's type.
+const EFFECTOR_FACTORIO_SHAPE = ["useModel", "createModel", "Provider", "@@unitShape"] as const
+
+type Options = {
+ detectCustomFactories: true | false | { allowlist: string[] }
+}
+
+export default createRule<[Options], "noFactoryInRender" | "noOperatorInRender" | "noCustomFactoryInRender">({
+ name: "no-units-spawn-in-render",
+ meta: {
+ type: "problem",
+ docs: {
+ description: "Forbid creating Effector units or calling operators inside React components or hooks.",
+ },
+ messages: {
+ noFactoryInRender:
+ 'Creating Effector units with "{{ name }}" inside React component or hook is forbidden, since it may cause memory leaks and other bugs.',
+ noOperatorInRender:
+ 'Using Effector operator "{{ name }}" inside React component or hook is forbidden, since it may cause memory leaks and other bugs.',
+ noCustomFactoryInRender:
+ 'Creating Effector units with "{{ name }}" inside React component or hook is forbidden, since it may cause memory leaks and other bugs. If this is a false positive, add "{{ name }}" to the allowlist in the detectCustomFactories option.',
+ },
+ schema: [
+ {
+ type: "object",
+ properties: {
+ detectCustomFactories: {
+ oneOf: [
+ { type: "boolean" },
+ {
+ type: "object",
+ properties: {
+ allowlist: { type: "array", items: { type: "string" }, uniqueItems: true },
+ },
+ required: ["allowlist"],
+ additionalProperties: false,
+ },
+ ],
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ },
+ defaultOptions: [{ detectCustomFactories: true }],
+ create: (context, [options]) => {
+ const services = ESLintUtils.getParserServices(context)
+ const checker = services.program.getTypeChecker()
+
+ const { detectCustomFactories } = options
+ const allowlist = typeof detectCustomFactories === "object" ? new Set(detectCustomFactories.allowlist) : undefined
+
+ // Tracks whether each nested function scope is a render context (component/hook).
+ // On function enter we push true/false, on exit we pop. Nested functions inherit: if the
+ // parent is a render context, all children are too (e.g. callbacks inside a component).
+ const stack = { render: [] as boolean[] }
+
+ // Maps local names of effector imports to their kind, so we can report them without type analysis.
+ // Populated from `import { createStore, sample } from "effector"` (handles renames too).
+ const effectorImports = new Map()
+
+ type ComponentFunction = ESNode.FunctionDeclaration | ESNode.FunctionExpression | ESNode.ArrowFunctionExpression
+
+ const importSelector = `ImportDeclaration[source.value=${PACKAGE_NAME.core}]`
+
+ return {
+ // ── Phase 1: Collect effector imports ──────────────────────────────────
+
+ [`${importSelector} > ImportSpecifier[imported.type="Identifier"]`]: (
+ node: ESNode.ImportSpecifier & { imported: ESNode.Identifier },
+ ) => {
+ const imported = node.imported.name
+ const local = node.local.name
+
+ if (EFFECTOR_FACTORIES.has(imported)) {
+ effectorImports.set(local, "factory")
+ } else if (EFFECTOR_OPERATORS.has(imported)) {
+ effectorImports.set(local, "operator")
+ }
+ },
+
+ // ── Phase 2: Track render scope via function enter/exit ────────────────
+ //
+ // Determines if a function is a render context using a series of heuristics,
+ // ordered from cheapest to most expensive:
+ // 1. Inherit from parent — if already inside render, every nested scope is too
+ // 2. Name check — `useXxx` convention means a custom hook
+ // 3. Return type — if the function returns JSX, it's a component
+ // 4. Contextual type — if the function is used where a component type is expected
+ // (e.g. React.memo, forwardRef), it's a component even without explicit annotation
+
+ [`FunctionDeclaration, FunctionExpression, ArrowFunctionExpression`]: (node: ComponentFunction) => {
+ const current = stack.render.at(-1) ?? false
+ if (current) return void stack.render.push(true)
+
+ const name = nameOf.function(node)
+ if (name && UseRegex.test(name.name)) return void stack.render.push(true)
+
+ const tsnode = services.esTreeNodeToTSNodeMap.get(node)
+
+ const signature = checker.getSignatureFromDeclaration(tsnode)
+ // Void is a safe fallback: if TS can't resolve a signature, it won't match JSX types,
+ // so the function won't be misclassified as a component
+ const returnType = signature ? checker.getReturnTypeOfSignature(signature) : checker.getVoidType()
+
+ const isJSX = returnType.isUnion()
+ ? returnType.types.some((type) => isType.jsx(type, services.program))
+ : isType.jsx(returnType, services.program)
+
+ if (isJSX) return void stack.render.push(true)
+
+ const inferred = (isExpression(tsnode) && getContextualType(checker, tsnode)) || checker.getUnknownType()
+
+ const isComponent = inferred.isUnion()
+ ? inferred.types.some((type) => isType.component(type, services.program))
+ : isType.component(inferred, services.program)
+
+ if (isComponent) return void stack.render.push(true)
+
+ return void stack.render.push(false)
+ },
+
+ [`:matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression):exit`]: () =>
+ void stack.render.pop(),
+
+ // Class bodies are never render contexts themselves — class methods (like render())
+ // will get their own stack entry and be evaluated independently.
+ "ClassDeclaration": () => void stack.render.push(false),
+ "ClassDeclaration:exit": () => void stack.render.pop(),
+
+ // ── Phase 3: Flag violating calls inside render ────────────────────────
+ //
+ // Detection is done in two tiers, ordered so we can avoid expensive type
+ // analysis when possible and catch operators whose return type is not a unit:
+ //
+ // Tier 1 — Import-based (no type analysis):
+ // If the callee was imported from effector (tracked in Phase 1), we know
+ // its kind immediately. This is essential for operators like `forward`
+ // and `guard` that return Subscription instead of a unit.
+ //
+ // Tier 2 — Type-based (requires type checker):
+ // For everything else, check if the call's return type contains effector
+ // units. If it does, classify the callee via typeMatchesSpecifier:
+ // - Known React hooks are excluded to avoid double-reporting: e.g.
+ // `useMemo(() => createStore(0), [])` returns Store, but the inner
+ // `createStore` and the like is already flagged — reporting `useMemo` too would be noise.
+ // `useContext` is excluded because it legitimately retrieves pre-created units.
+ // - effector-factorio's `factory.useModel()` is excluded — it retrieves
+ // pre-created units from React context, similar to useContext
+ // - Namespaced effector calls (e.g. `effector.createStore`) are matched
+ // by callee type against the effector package
+ // - Anything remaining is treated as a custom factory
+
+ "CallExpression": (node: ESNode.CallExpression) => {
+ const isWithinRender = stack.render.at(-1) ?? false
+ if (!isWithinRender) return
+
+ const calleeName = getCalleeName(node.callee)
+
+ // Tier 1: known effector import — report immediately, skip type analysis
+ const importType = calleeName ? effectorImports.get(calleeName) : undefined
+ switch (importType) {
+ case "factory":
+ return context.report({ node, messageId: "noFactoryInRender", data: { name: calleeName } })
+ case "operator":
+ return context.report({ node, messageId: "noOperatorInRender", data: { name: calleeName } })
+ }
+
+ // Tier 2: type-based detection — skip entirely if custom factories are disabled
+ if (detectCustomFactories === false) return
+
+ const returnType = services.getTypeAtLocation(node)
+ const ctx: TraverseCtx = { node: services.esTreeNodeToTSNodeMap.get(node), checker, program: services.program }
+
+ if (!hasEffectorUnitInType(ctx, returnType)) return
+
+ const calleeType = services.getTypeAtLocation(node.callee)
+ const displayName = calleeName ?? ""
+
+ if (typeMatchesSpecifier(calleeType, REACT_HOOKS_SPEC, services.program)) return
+ if (isEffectorFactorioHook(node.callee, services.getTypeAtLocation)) return
+
+ if (typeMatchesSpecifier(calleeType, EFFECTOR_FACTORY_SPEC, services.program))
+ return context.report({ node, messageId: "noFactoryInRender", data: { name: displayName } })
+
+ if (typeMatchesSpecifier(calleeType, EFFECTOR_OPERATOR_SPEC, services.program))
+ return context.report({ node, messageId: "noOperatorInRender", data: { name: displayName } })
+
+ if (allowlist && calleeName && allowlist.has(calleeName)) return
+
+ context.report({ node, messageId: "noCustomFactoryInRender", data: { name: displayName } })
+ },
+ }
+ },
+})
+
+const UseRegex = /^use[A-Z0-9].*$/
+
+function getCalleeName(callee: ESNode.Expression): string | null {
+ if (callee.type === AST_NODE_TYPES.Identifier) return callee.name
+ if (callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier)
+ return callee.property.name
+ else return null
+}
+
+type TraverseCtx = { node: TSNode; checker: TypeChecker; program: Program }
+
+// Walks the type structure up to `depth` levels of object nesting to find effector units.
+// Unions don't consume depth — they are alternative shapes at the same level.
+function hasEffectorUnitInType(ctx: TraverseCtx, type: Type, depth = 3): boolean {
+ if (isType.unit(type, ctx.program)) return true
+ if (depth <= 0) return false
+
+ // For unions, getProperties() only returns common properties across all members.
+ // We must recurse into each member to check their individual properties for userland factories.
+ if (type.isUnion()) return type.types.some((type) => hasEffectorUnitInType(ctx, type, depth))
+
+ for (const property of type.getProperties()) {
+ const type = ctx.checker.getTypeOfSymbolAtLocation(property, ctx.node)
+ if (hasEffectorUnitInType(ctx, type, depth - 1)) return true
+ }
+
+ return false
+}
+
+// Checks if the callee is a method call on an effector-factorio factory object (e.g. `factory.useModel()`).
+// Matches by structural shape of the receiver: must have useModel, createModel, Provider, and @@unitShape.
+function isEffectorFactorioHook(callee: ESNode.Expression, getTypeAtLocation: (node: ESNode.Node) => Type): boolean {
+ if (callee.type !== AST_NODE_TYPES.MemberExpression) return false
+
+ const objectType = getTypeAtLocation(callee.object)
+ const propertyNames = new Set(objectType.getProperties().map((p) => p.getName()))
+
+ return EFFECTOR_FACTORIO_SHAPE.every((name) => propertyNames.has(name))
+}
diff --git a/src/ruleset.ts b/src/ruleset.ts
index b11fbd2..25fc617 100644
--- a/src/ruleset.ts
+++ b/src/ruleset.ts
@@ -27,6 +27,7 @@ const scope = {
const react = {
"effector/enforce-gate-naming-convention": "error",
"effector/mandatory-scope-binding": "error",
+ "effector/no-units-spawn-in-render": "error",
"effector/prefer-useUnit": "error",
} satisfies TSESLint.Linter.RulesRecord
diff --git a/src/shared/is.ts b/src/shared/is.ts
index 409dc6d..f20c781 100644
--- a/src/shared/is.ts
+++ b/src/shared/is.ts
@@ -24,7 +24,7 @@ export const isType = {
typeMatchesSpecifier(type, { from: "package", package: "effector", name: "Effect" }, program),
unit: (type: Type, program: Program) => {
- const name = ["Store", "StoreWritable", "Event", "EventCallable", "Effect"]
+ const name = ["Store", "StoreWritable", "Event", "EventCallable", "Effect", "Domain"]
return typeMatchesSpecifier(type, { from: "package", package: "effector", name }, program)
},