/**
* Rule: prefer-store-selectors
*
* Detects when derived state from Zustand stores should be moved to selectors
* instead of computing it with useMemo in components.
*
* Examples:
* - Bad: const items = useStore(s => s.items); const filtered = useMemo(() => items.filter(...), [items]);
* - Bad: const data = useStore(s => s.data); const mapped = useMemo(() => data.map(...), [data]);
* - Good: const filteredItems = useStore(selectFilteredItems);
* - Good: const mappedData = useStore(selectMappedData);
*/
import { createRule, defineRuleMeta } from "../utils/create-rule.js";
import type { TSESTree } from "@typescript-eslint/utils";
type MessageIds = "useMemoWithStoreData" | "chainedDerivedState";
type Options = [
{
/** Regex pattern for store hook names (default: "^use.*Store$") */
storeHookPattern?: string;
}
];
/**
* Rule metadata - colocated with implementation for maintainability
*/
export const meta = defineRuleMeta({
id: "prefer-store-selectors",
version: "1.0.0",
name: "Prefer Store Selectors",
description: "Derived state from store should use selectors, not useMemo",
defaultSeverity: "warn",
category: "static",
icon: "🏪",
hint: "Move derived state to store selectors",
defaultEnabled: true,
defaultOptions: [{ storeHookPattern: "^use.*Store$" }],
optionSchema: {
fields: [
{
key: "storeHookPattern",
label: "Store hook pattern",
type: "text",
defaultValue: "^use.*Store$",
description: "Regex pattern for identifying Zustand store hooks",
},
],
},
docs: `
## What it does
Detects when derived state computed from Zustand store data using \`useMemo\`
should instead be moved to a store selector for better performance and cleaner code.
## Why it's useful
- **Performance**: Selectors are memoized at the store level, avoiding recomputation
- **Reusability**: Selectors can be shared across components
- **Testability**: Selectors are pure functions that are easy to unit test
- **Separation of concerns**: Keeps data transformation logic out of components
## Examples
### ❌ Incorrect
\`\`\`tsx
function ProductList() {
const products = useStore((s) => s.products);
// Derived state computed in component - should be a selector
const activeProducts = useMemo(
() => products.filter((p) => p.isActive),
[products]
);
return
;
}
// Multiple chained useMemo calls
function Dashboard() {
const data = useDataStore((s) => s.data);
const filtered = useMemo(() => data.filter(isValid), [data]);
const sorted = useMemo(() => filtered.sort(byDate), [filtered]);
const mapped = useMemo(() => sorted.map(format), [sorted]);
return
;
}
\`\`\`
### ✅ Correct
\`\`\`tsx
// Define selectors in the store file
const selectActiveProducts = (state) =>
state.products.filter((p) => p.isActive);
function ProductList() {
// Use selector for derived state
const activeProducts = useStore(selectActiveProducts);
return
;
}
// Combined selector for dashboard
const selectFormattedData = (state) =>
state.data
.filter(isValid)
.sort(byDate)
.map(format);
function Dashboard() {
const formattedData = useDataStore(selectFormattedData);
return ;
}
\`\`\`
## Configuration
\`\`\`js
// eslint.config.js
"uilint/prefer-store-selectors": ["warn", {
storeHookPattern: "^use.*Store$" // Match useXxxStore pattern
}]
\`\`\`
`,
});
/**
* Transformation methods that indicate derived state computation
*/
const TRANSFORMATION_METHODS = new Set([
"filter",
"map",
"reduce",
"sort",
"flat",
"flatMap",
"slice",
"concat",
"find",
"findIndex",
"some",
"every",
"includes",
"reverse",
"join",
]);
/**
* Global functions that transform arrays
*/
const ARRAY_TRANSFORMATION_FUNCTIONS = new Set([
"Array.from",
"Object.keys",
"Object.values",
"Object.entries",
]);
/**
* Check if a node is a Zustand store call based on the pattern
*/
function isZustandStoreCall(
node: TSESTree.CallExpression,
storePattern: RegExp
): boolean {
if (node.callee.type === "Identifier") {
return storePattern.test(node.callee.name);
}
return false;
}
/**
* Check if a node is a useMemo call
*/
function isUseMemoCall(node: TSESTree.CallExpression): boolean {
return node.callee.type === "Identifier" && node.callee.name === "useMemo";
}
/**
* Get the variable name from a variable declarator
*/
function getVariableName(
node: TSESTree.VariableDeclarator
): string | null {
if (node.id.type === "Identifier") {
return node.id.name;
}
return null;
}
/**
* Check if a call expression is a transformation method on an identifier
*/
function isTransformationCall(
node: TSESTree.CallExpression,
trackedVars: Set
): { isTransform: boolean; varName: string | null } {
// Check for method calls like items.filter(), items.map()
if (node.callee.type === "MemberExpression") {
const { object, property } = node.callee;
if (
object.type === "Identifier" &&
property.type === "Identifier" &&
trackedVars.has(object.name) &&
TRANSFORMATION_METHODS.has(property.name)
) {
return { isTransform: true, varName: object.name };
}
// Check for chained calls like items.filter().map()
if (object.type === "CallExpression") {
const nested = isTransformationCall(object, trackedVars);
if (nested.isTransform) {
return nested;
}
}
}
// Check for Array.from(items), Object.keys(items), etc.
if (node.callee.type === "MemberExpression") {
const { object, property } = node.callee;
if (
object.type === "Identifier" &&
property.type === "Identifier"
) {
const funcName = `${object.name}.${property.name}`;
if (ARRAY_TRANSFORMATION_FUNCTIONS.has(funcName)) {
// Check if the first argument is a tracked variable
if (
node.arguments.length > 0 &&
node.arguments[0].type === "Identifier" &&
trackedVars.has(node.arguments[0].name)
) {
return { isTransform: true, varName: node.arguments[0].name };
}
}
}
}
return { isTransform: false, varName: null };
}
/**
* Check if a node references any of the tracked variables
*/
function referencesTrackedVar(
node: TSESTree.Node,
trackedVars: Set
): string | null {
if (node.type === "Identifier" && trackedVars.has(node.name)) {
return node.name;
}
if (node.type === "MemberExpression") {
return referencesTrackedVar(node.object, trackedVars);
}
if (node.type === "CallExpression") {
// Check callee
const calleeRef = referencesTrackedVar(node.callee, trackedVars);
if (calleeRef) return calleeRef;
// Check arguments
for (const arg of node.arguments) {
const argRef = referencesTrackedVar(arg, trackedVars);
if (argRef) return argRef;
}
}
if (node.type === "BinaryExpression" || node.type === "LogicalExpression") {
const leftRef = referencesTrackedVar(node.left, trackedVars);
if (leftRef) return leftRef;
return referencesTrackedVar(node.right, trackedVars);
}
if (node.type === "ConditionalExpression") {
const testRef = referencesTrackedVar(node.test, trackedVars);
if (testRef) return testRef;
const consequentRef = referencesTrackedVar(node.consequent, trackedVars);
if (consequentRef) return consequentRef;
return referencesTrackedVar(node.alternate, trackedVars);
}
if (node.type === "ArrayExpression") {
for (const element of node.elements) {
if (element) {
const elemRef = referencesTrackedVar(element, trackedVars);
if (elemRef) return elemRef;
}
}
}
if (node.type === "SpreadElement") {
return referencesTrackedVar(node.argument, trackedVars);
}
return null;
}
/**
* Analyze useMemo body to detect if it performs transformations on store data
*/
function analyzeUseMemoBody(
body: TSESTree.Node,
trackedVars: Set
): { hasTransformation: boolean; varName: string | null } {
// Handle arrow function with expression body: () => items.filter(...)
if (body.type === "CallExpression") {
const result = isTransformationCall(body, trackedVars);
if (result.isTransform) {
return { hasTransformation: true, varName: result.varName };
}
}
// Handle arrow function with block body: () => { return items.filter(...); }
if (body.type === "BlockStatement") {
for (const statement of body.body) {
if (statement.type === "ReturnStatement" && statement.argument) {
if (statement.argument.type === "CallExpression") {
const result = isTransformationCall(statement.argument, trackedVars);
if (result.isTransform) {
return { hasTransformation: true, varName: result.varName };
}
}
// Check if return value references tracked vars with any transformation
const varRef = referencesTrackedVar(statement.argument, trackedVars);
if (varRef && statement.argument.type === "CallExpression") {
return { hasTransformation: true, varName: varRef };
}
}
// Check variable declarations inside useMemo
if (statement.type === "VariableDeclaration") {
for (const decl of statement.declarations) {
if (decl.init && decl.init.type === "CallExpression") {
const result = isTransformationCall(decl.init, trackedVars);
if (result.isTransform) {
return { hasTransformation: true, varName: result.varName };
}
}
}
}
}
}
return { hasTransformation: false, varName: null };
}
/**
* Get the callback body from useMemo arguments
*/
function getUseMemoCallback(
node: TSESTree.CallExpression
): TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | null {
if (node.arguments.length === 0) return null;
const callback = node.arguments[0];
if (
callback.type === "ArrowFunctionExpression" ||
callback.type === "FunctionExpression"
) {
return callback;
}
return null;
}
export default createRule({
name: "prefer-store-selectors",
meta: {
type: "suggestion",
docs: {
description:
"Derived state from store should use selectors, not useMemo",
},
messages: {
useMemoWithStoreData:
"useMemo derives state from '{{varName}}' which comes from store. Move this computation to a Zustand selector.",
chainedDerivedState:
"Multiple chained useMemo calls derive from store data. Consolidate into store selectors.",
},
schema: [
{
type: "object",
properties: {
storeHookPattern: {
type: "string",
description: "Regex pattern for store hook names",
},
},
additionalProperties: false,
},
],
},
defaultOptions: [
{
storeHookPattern: "^use.*Store$",
},
],
create(context) {
const options = context.options[0] || {};
const storeHookPatternStr = options.storeHookPattern ?? "^use.*Store$";
let storePattern: RegExp;
try {
storePattern = new RegExp(storeHookPatternStr);
} catch {
// If invalid regex, use default
storePattern = /^use.*Store$/;
}
// Scope tracking - each function gets its own scope
interface Scope {
storeVars: Set;
derivedMemoVars: Set;
useMemoNodes: Array<{
node: TSESTree.CallExpression;
varName: string;
sourceVar: string;
}>;
}
const scopeStack: Scope[] = [];
function currentScope(): Scope | undefined {
return scopeStack[scopeStack.length - 1];
}
function pushScope(): void {
scopeStack.push({
storeVars: new Set(),
derivedMemoVars: new Set(),
useMemoNodes: [],
});
}
function popScope(): Scope | undefined {
return scopeStack.pop();
}
function reportScope(scope: Scope): void {
const { useMemoNodes, derivedMemoVars } = scope;
if (useMemoNodes.length === 1) {
const { node, sourceVar } = useMemoNodes[0];
context.report({
node,
messageId: "useMemoWithStoreData",
data: { varName: sourceVar },
});
} else if (useMemoNodes.length > 1) {
// Check if there's a chain (one useMemo depends on another)
const hasChain = useMemoNodes.some(({ sourceVar }) =>
derivedMemoVars.has(sourceVar)
);
if (hasChain) {
// Report chained derived state on the first node
context.report({
node: useMemoNodes[0].node,
messageId: "chainedDerivedState",
});
} else {
// Report each useMemo individually
for (const { node, sourceVar } of useMemoNodes) {
context.report({
node,
messageId: "useMemoWithStoreData",
data: { varName: sourceVar },
});
}
}
}
}
/**
* Check if a function is likely a React component or hook (not an inline callback)
*/
function isComponentOrHook(
node:
| TSESTree.FunctionDeclaration
| TSESTree.FunctionExpression
| TSESTree.ArrowFunctionExpression
): boolean {
// Function declarations are typically components or hooks
if (node.type === "FunctionDeclaration") {
return true;
}
// Check if assigned to a PascalCase variable (component) or camelCase starting with "use" (hook)
if (node.parent?.type === "VariableDeclarator") {
const declarator = node.parent;
if (declarator.id.type === "Identifier") {
const name = declarator.id.name;
// PascalCase (component) or useXxx (hook)
return /^[A-Z]/.test(name) || /^use[A-Z]/.test(name);
}
}
// Named function expression
if (node.type === "FunctionExpression" && node.id) {
const name = node.id.name;
return /^[A-Z]/.test(name) || /^use[A-Z]/.test(name);
}
return false;
}
return {
// Push scope for component/hook functions
"FunctionDeclaration, FunctionExpression, ArrowFunctionExpression"(
node:
| TSESTree.FunctionDeclaration
| TSESTree.FunctionExpression
| TSESTree.ArrowFunctionExpression
) {
if (isComponentOrHook(node)) {
pushScope();
}
},
// Pop scope and report when exiting component/hook functions
"FunctionDeclaration:exit"(node: TSESTree.FunctionDeclaration) {
if (isComponentOrHook(node)) {
const scope = popScope();
if (scope) {
reportScope(scope);
}
}
},
"FunctionExpression:exit"(node: TSESTree.FunctionExpression) {
if (isComponentOrHook(node)) {
const scope = popScope();
if (scope) {
reportScope(scope);
}
}
},
"ArrowFunctionExpression:exit"(node: TSESTree.ArrowFunctionExpression) {
if (isComponentOrHook(node)) {
const scope = popScope();
if (scope) {
reportScope(scope);
}
}
},
// Also handle program-level code
Program() {
pushScope();
},
"Program:exit"() {
const scope = popScope();
if (scope) {
reportScope(scope);
}
},
// Track variable declarations from store hooks
VariableDeclarator(node) {
const scope = currentScope();
if (!scope) return;
if (!node.init || node.init.type !== "CallExpression") {
return;
}
const varName = getVariableName(node);
if (!varName) return;
// Check if this is a store hook call
if (isZustandStoreCall(node.init, storePattern)) {
scope.storeVars.add(varName);
return;
}
// Check if this is a useMemo call
if (isUseMemoCall(node.init)) {
const callback = getUseMemoCallback(node.init);
if (!callback) return;
const body = callback.body;
// Check if useMemo uses store-derived variables
const allTracked = new Set();
scope.storeVars.forEach((v) => allTracked.add(v));
scope.derivedMemoVars.forEach((v) => allTracked.add(v));
const analysis = analyzeUseMemoBody(body, allTracked);
if (analysis.hasTransformation && analysis.varName) {
// Track this useMemo result as derived from store
scope.derivedMemoVars.add(varName);
scope.useMemoNodes.push({
node: node.init,
varName,
sourceVar: analysis.varName,
});
}
}
},
};
},
});