Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/forty-wolves-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-plugin-effector": minor
---

New rule `no-units-spawn-in-render`
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/rules/no-units-spawn-in-render/fixtures/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createStore } from "effector"
import { createContext } from "react"

const $store = createStore(0)

export const ModelContext = createContext({ $store })
12 changes: 12 additions & 0 deletions src/rules/no-units-spawn-in-render/fixtures/factorio.ts
Original file line number Diff line number Diff line change
@@ -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 }
})
30 changes: 30 additions & 0 deletions src/rules/no-units-spawn-in-render/fixtures/factory.ts
Original file line number Diff line number Diff line change
@@ -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" }
}
138 changes: 138 additions & 0 deletions src/rules/no-units-spawn-in-render/no-units-spawn-in-render.md
Original file line number Diff line number Diff line change
@@ -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 <button onClick={clicked}>click</button>
}

// 👎 Units inside hooks (useMemo, useEffect, useCallback) are still problematic
function Component() {
const $store = useMemo(() => createStore(0), [])

useEffect(() => {
sample({ clock: clicked, target: $store })
}, [])

return <div>hello</div>
}

// 👎 Custom factory functions that return units are also forbidden
function createModel() {
const $store = createStore(0)
return { $store }
}

function Component() {
const model = createModel()
return <div>hello</div>
}

// 👎 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 <button onClick={click}>{value}</button>
}

// 👍 Units accessed via useContext are OK
const ModelContext = createContext({ $store })

function Component() {
const { $store } = useContext(ModelContext)
const value = useUnit($store)

return <div>{value}</div>
}

// 👍 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 <button onClick={inc}>{count}</button>
}
```

## 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.
Loading
Loading