Skip to content

feat(use-unit-destructuring): add new rule for destructured units#175

Open
Olovyannikov wants to merge 9 commits intoeffector:masterfrom
Olovyannikov:new/use-unit-destructuring
Open

feat(use-unit-destructuring): add new rule for destructured units#175
Olovyannikov wants to merge 9 commits intoeffector:masterfrom
Olovyannikov:new/use-unit-destructuring

Conversation

@Olovyannikov
Copy link
Copy Markdown

@Olovyannikov Olovyannikov commented Dec 9, 2025

Problem: implicit subscriptions when forgot remove unused subscriptions inside react components
e.g.:

const { setValue } = useUnit({ 
  value: $store, // not used, but create unnecessary re-render
  setValue: event 
})

Why is this important?

Implicit subscriptions can lead to:

  • Performance issues: unnecessary re-renders when unused stores update
  • Hard-to-debug behavior: component re-renders for unclear reasons
  • Memory leaks: subscriptions that are never cleaned up properly

Rule Details

This rule enforces that:

  • All properties passed in an object to useUnit must be destructured to prevent implicit subscriptions;
  • All elements passed in an array to useUnit must be destructured to prevent implicit subscriptions also.

@netlify
Copy link
Copy Markdown

netlify bot commented Dec 9, 2025

Deploy Preview for eslint-plugin ready!

Name Link
🔨 Latest commit aa98a41
🔍 Latest deploy log https://app.netlify.com/projects/eslint-plugin/deploys/69db64521be4460008297b60
😎 Deploy Preview https://deploy-preview-175--eslint-plugin.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@Olovyannikov Olovyannikov force-pushed the new/use-unit-destructuring branch 2 times, most recently from ddd9966 to 9accf5e Compare December 9, 2025 10:05
@Olovyannikov Olovyannikov changed the title feat(rule): add new rule for destructured units feat(use-unit-desctricturing): add new rule for destructured units Dec 14, 2025
@Olovyannikov Olovyannikov changed the title feat(use-unit-desctricturing): add new rule for destructured units feat(use-unit-destructuring): add new rule for destructured units Dec 15, 2025
@Olovyannikov Olovyannikov force-pushed the new/use-unit-destructuring branch from 9accf5e to 6c9cd69 Compare March 4, 2026 08:13
@Olovyannikov Olovyannikov force-pushed the new/use-unit-destructuring branch from 80edafd to e8e65b7 Compare April 10, 2026 08:25
Copy link
Copy Markdown
Contributor

@kireevmp kireevmp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Olovyannikov Thanks for taking the time to submit this! The rule logic seems to be solid 👍

There are a couple of good opportunities to improve this though – I'd appreciate it if you can take a look at my comments

@Olovyannikov Olovyannikov requested a review from kireevmp April 10, 2026 17:52
Comment on lines +112 to +124
"ImportDeclaration"(node): void {
if (node.source.value !== "effector-react") return

for (const specifier of node.specifiers) {
if (
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
specifier.imported.type === AST_NODE_TYPES.Identifier &&
specifier.imported.name === "useUnit"
) {
importedAs.add(specifier.local.name)
}
}
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manual traversal of all specifiers can be avoided given the following selector, which is widely accepted in many other rules

const selector = {
  import: `ImportDeclaration[source.value=${PACKAGE_NAME.react}] > ImportSpecifier[imported.name=useUnit]`,
}

This not only finds specific instances of useUnit aliases, but also supports other effector-react imports via regex such as effector-react/scope (deprecated but still used).

With this, the code reduces to

return {
  [selector.import]: (node: TSESTree.ImportSpecifier) => void importedAs.add(node.local.name),

Comment on lines +126 to +135
"VariableDeclarator[id.type='ObjectPattern'] > CallExpression[arguments.length=1][callee.type='Identifier']"(
node: TSESTree.CallExpression,
): void {
if (!importedAs.has((node.callee as TSESTree.Identifier).name)) return
const argument = node.arguments[0]

if (argument?.type !== AST_NODE_TYPES.ObjectExpression) return
const parent = node.parent as TSESTree.VariableDeclarator

if (parent.id.type !== AST_NODE_TYPES.ObjectPattern) return
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many of these checks can be delegated to esquery as discussed previously. It also allows us to select a specific subject (with ESLint via :has selector)

const selector = {
  variable: { shape: "VariableDeclarator[id.type=ObjectPattern]", list: "VariableDeclarator[id.type=ArrayPattern]" },
  call: "CallExpression.init[arguments.length=1][callee.type=Identifier]",
  arg: { shape: "ObjectExpression.arguments", list: "ArrayExpression.arguments" },
}

The above group of selectors allows detecting all two combinations via

return {
  [`${selector.variable.shape}:has(> ${selector.call}:has(${selector.arg.shape}))`]: (node: ShapeCall): void => { ... },
  [`${selector.variable.list}:has(> ${selector.call}:has(${selector.arg.list}))`]: (node: ListCall): void => { ... },
}

And these selectors provide the following TS guarantees

type ListCall = Node.VariableDeclarator & {
  init: Node.CallExpression & { callee: Node.Identifier; arguments: [Node.ArrayExpression] }
  id: Node.ArrayPattern
}

type ShapeCall = Node.VariableDeclarator & {
  init: Node.CallExpression & { callee: Node.Identifier; arguments: [Node.ObjectExpression] }
  id: Node.ObjectPattern
}

Which completely eliminates the need for all guards except importedAs.has

Comment on lines +27 to +39
function getPropertyKey(prop: TSESTree.Property | TSESTree.RestElement | TSESTree.SpreadElement): string | null {
if (prop.type !== AST_NODE_TYPES.Property) return null

if (prop.key.type === AST_NODE_TYPES.Identifier && !prop.computed) {
return prop.key.name
}

if (prop.key.type === AST_NODE_TYPES.Literal && typeof prop.key.value === "string" && !prop.computed) {
return prop.key.value
}

return null
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function can be moved out of create to be static (rather than re-created on each file) and also simplified by type-guarding against computed first

type ShapeProperty = Node.Property | Node.RestElement | Node.ObjectLiteralElement

function toKey(prop: ShapeProperty): string | number | null {
  if (prop.type !== NodeType.Property || prop.computed) return null
  if (prop.key.type === NodeType.Identifier) return prop.key.name
  if (prop.key.type === NodeType.Literal) return prop.key.value
  return null
}

Comment on lines +41 to +52
function getObjectKeys(
objectArgument: TSESTree.ObjectExpression,
objectPattern: TSESTree.ObjectPattern,
): { argumentKeys: string[]; destructuredKeys: string[]; keyToName: Map<string, string> } {
const argumentKeys = objectArgument.properties.map(getPropertyKey).filter((key): key is string => key !== null)

const destructuredKeys = objectPattern.properties.map(getPropertyKey).filter((key): key is string => key !== null)

const keyToName = new Map(argumentKeys.map((key) => [key, key]))

return { argumentKeys, destructuredKeys, keyToName }
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. This function does not capture anything from context as well, so can be moved out.
  2. I don't see a specific reason to work with both argument and destructured keys in the same function, given that the logic is the same. It would be best to reuse the same logic by calling it twice, separately on lefthand and righthand side.
  3. We should also build in the rest/spread logic by bailing early on any "weird" usage where we can't track what's provided to useUnit and what's consumed by the component.
  4. We can also postpone keyToName generation until we've found the violation and ready to report

The shape -> key mapper can then be simplified to work along these lines

type ValueNode = Node.Property["key"]

function shapeToKeyMap(shape: Node.ObjectPattern | Node.ObjectExpression): Map<string | number, ValueNode> | null {
  const map = new Map<string | number, ValueNode>()

  for (const prop of shape.properties) {
    const key = toKey(prop)

    if (key === null || prop.type === NodeType.RestElement || prop.type === NodeType.SpreadElement)
      return null /* can't see what is consumed/provided, so skip to reduce false positives */
    else map.set(key, prop.key)
  }

  return map
}

This is called twice, first for ObjectPattern and then for ObjectExpression. The prop.key is stored for later to allow for formatting the error, but is untouched until that is needed.

Comment on lines +54 to +81
function getArrayKeys(
arrayArgument: TSESTree.ArrayExpression,
arrayPattern: TSESTree.ArrayPattern,
): { argumentKeys: string[]; destructuredKeys: string[]; keyToName: Map<string, string> } {
const argumentKeys: string[] = []
const keyToName = new Map<string, string>()

arrayArgument.elements.forEach((el, i) => {
if (el === null || el.type === AST_NODE_TYPES.SpreadElement) return

const key = String(i)
argumentKeys.push(key)

if (el.type === AST_NODE_TYPES.Identifier) {
keyToName.set(key, el.name)
} else if (el.type === AST_NODE_TYPES.MemberExpression) {
keyToName.set(key, context.sourceCode.getText(el))
} else {
keyToName.set(key, key)
}
})

const destructuredKeys = arrayPattern.elements
.map((el, i) => (el !== null && el.type !== AST_NODE_TYPES.RestElement ? String(i) : null))
.filter((key): key is string => key !== null)

return { argumentKeys, destructuredKeys, keyToName }
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Same thoughts here – no context capture suggests moving out of create and working with both lefthand and righthand sides makes no sense when we can call the function twice.
  2. context.sourceCode.getText(node) is dependent on source code formatting and is not reliable, since it may produce "new line" character or other stuff we're not ready to work with. One other rule currently handles this by traversing the MemberExpression and joining the path recursively.
  3. This logic should also consider bailing on spread and rest elements, since those completely remove our ability to judge what's provided and consumed.

With these thoughts, consider the following rough implementation

type ValueNode = Exclude<Node.DestructuringPattern, Node.RestElement> | null

function listToKeyMap(list: Node.ArrayPattern | Node.ArrayExpression): Map<number, ValueNode> | null {
  const map = new Map<number, ValueNode>()

  for (const [index, element] of list.elements.entries())
    if (element?.type === NodeType.RestElement || element?.type === NodeType.SpreadElement) return null
    else if (element === null) continue /* leave a hole as non-consuming */
    else map.set(index, element)

  return map
}

valid: [
{
name: "All keys were destructured (object shape)",
code: `
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every test case in this repo uses ts or tsx helper from "@/shared/tag" which trims spaces and newlines. It also works really nice with prettier-plugin-embed to provide automatic formatting (i.e. remove semicolons).

For intended cases like quoted "setValue" use // prettier-ignore

src/index.ts Outdated
Comment on lines 51 to 53
"use-unit-destructuring": useUnitDestructuring,
"require-pickup-in-persist": requirePickupInPersist,
"strict-effect-handlers": strictEffectHandlers,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you also kindly sort this rule list alphabetically, like with the imports?

@Olovyannikov Olovyannikov force-pushed the new/use-unit-destructuring branch from 8676017 to 61efbe7 Compare April 12, 2026 09:15
@Olovyannikov Olovyannikov requested a review from kireevmp April 12, 2026 09:15
@Olovyannikov Olovyannikov force-pushed the new/use-unit-destructuring branch 2 times, most recently from 23f77aa to ed112f1 Compare April 12, 2026 09:21
@Olovyannikov Olovyannikov force-pushed the new/use-unit-destructuring branch from ed112f1 to aa98a41 Compare April 12, 2026 09:22
Copy link
Copy Markdown
Contributor

@kireevmp kireevmp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Olovyannikov Thank you for committing your time to polish this PR! I think these are just final cosmetic touches on code location and consistency. Once fixed I believe we'll be in good shape to merge.

@@ -0,0 +1,157 @@
---
description: Ensures that all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: Ensures that all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders.
description: Ensure all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders

Just a small touch-up to remove the dot and use correct form.

Comment on lines +47 to +48
unusedKey: 'Property "{{key}}" is passed but not destructured.',
missingKey: 'Property "{{key}}" is destructured but not passed in the unit object.',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
unusedKey: 'Property "{{key}}" is passed but not destructured.',
missingKey: 'Property "{{key}}" is destructured but not passed in the unit object.',
unusedKey: 'Property "{{name}}" is passed but not destructured.',
missingKey: 'Property "{{name}}" is destructured but not passed in the unit object.',

Very much optional, but this will remove the data: { key: name } in favor of data: { name } shorthand.

create(context) {
const importedAs = new Set<string>()

function handleObjectPattern(objectArgument: Node.ObjectExpression, objectPattern: Node.ObjectPattern): void {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to keep those handle* in separate functions, or would it be possible to just inline those in the visitor?

} as const

export default createRule<Options, MessageIds>({
name: "prefer-useUnit-destructuring",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A question on rule naming – why were you inclined to change the previous name useUnit-destructuring and add prefer-?

In case with prefer-useUnit, the community prefers useUnit over useStore and others.

With these rule, I believe there's nothing to prefer. Rather, if a rule name prefix is wanted, I'd suggest ensure-useUnit-destructuring becase we ensure all units are destructured. What do you think? Which option you think is best here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these new rule-specific functions placed in a shared file that explicitly only houses createRule? These are very rule-specific, so I'd be inclined to keep them in the test rule file.

Comment on lines +20 to +25
for (const prop of shape.properties) {
if (prop.type === AST_NODE_TYPES.RestElement || prop.type === AST_NODE_TYPES.SpreadElement) return null
const key = toKey(prop)
if (key === null) return null
map.set(key, prop.key as ValueNode)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toKey already covers the RestElement and SpreadElement checks via prop.type !== AST_NODE_TYPES.Property. So in my previous comment with the rough implementation the if was solely as a type guard.

key acts as the discriminator in this case. Consider either type cast (which is sound) or other way to force TS to narrow down the value.

for (const prop of shape.properties) {
  const key = toKey(prop)

  if (key === null) return null
  else map.set(key, (prop as Node.Property).key)
}

There's also an option to replace Rest/SpreadElement check with the prop.type !== AST_NODE_TYPES.Property check inside the loop, removing it from toKey and narrowing down the argument type of toKey to reflect that.

Comment on lines +33 to +35
for (const [index, element] of list.elements.entries()) {
if (element?.type === AST_NODE_TYPES.RestElement || element?.type === AST_NODE_TYPES.SpreadElement) return null
if (element === null) continue
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might make sense to reverse check to get rid of .? operator

Suggested change
for (const [index, element] of list.elements.entries()) {
if (element?.type === AST_NODE_TYPES.RestElement || element?.type === AST_NODE_TYPES.SpreadElement) return null
if (element === null) continue
for (const [index, element] of list.elements.entries()) {
if (element === null) continue
if (element.type === AST_NODE_TYPES.RestElement || element.type === AST_NODE_TYPES.SpreadElement) return null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new code in this file is also very rule-specific, consider moving back to the rule file.

meta: {
type: "problem",
docs: {
description: "Ensure destructured properties match the passed unit object/array",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: "Ensure destructured properties match the passed unit object/array",
description: "Ensure all units passed to useUnit are properly destructured.",

It's best to keep this very close to frontmatter in docs, and also mention useUnit.

@@ -1,3 +1,6 @@
.idea
**/*.xml
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's rather blunt to ignore all XML files, even though we don't use them at the moment. Can this be more specific, and preferably under a named section like ignores below?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants