/** * Rule: zustand-use-selectors * * Requires selector functions when accessing Zustand store state to prevent * unnecessary re-renders. * * Examples: * - Bad: const state = useStore() * - Bad: const { count } = useStore() * - Good: const count = useStore((s) => s.count) * - Good: const count = useStore(selectCount) */ import { createRule, defineRuleMeta } from "../utils/create-rule.js"; import type { TSESTree } from "@typescript-eslint/utils"; type MessageIds = "missingSelector" | "useSelectorFunction"; type Options = [ { /** Regex pattern for store hook names (default: "^use\\w*Store$") */ storePattern?: string; /** Allow useShallow() wrapper without selector */ allowShallow?: boolean; /** Require named selector functions instead of inline arrows */ requireNamedSelectors?: boolean; } ]; /** * Rule metadata - colocated with implementation for maintainability */ export const meta = defineRuleMeta({ id: "zustand-use-selectors", version: "1.0.0", name: "Zustand Use Selectors", description: "Require selector functions when accessing Zustand store state", defaultSeverity: "warn", category: "static", icon: "⚡", hint: "Prevents unnecessary re-renders", defaultEnabled: true, defaultOptions: [ { storePattern: "^use\\w*Store$", allowShallow: true, requireNamedSelectors: false }, ], optionSchema: { fields: [ { key: "storePattern", label: "Store hook pattern", type: "text", defaultValue: "^use\\w*Store$", description: "Regex pattern for identifying Zustand store hooks", }, { key: "allowShallow", label: "Allow useShallow", type: "boolean", defaultValue: true, description: "Allow useShallow() wrapper without explicit selector", }, { key: "requireNamedSelectors", label: "Require named selectors", type: "boolean", defaultValue: false, description: "Require named selector functions instead of inline arrows", }, ], }, docs: ` ## What it does Enforces the use of selector functions when accessing Zustand store state. When you call a Zustand store without a selector, your component subscribes to the entire store and re-renders on any state change. ## Why it's useful - **Performance**: Prevents unnecessary re-renders - **Optimization**: Components only update when selected state changes - **Best Practice**: Follows Zustand's recommended patterns ## Examples ### ❌ Incorrect \`\`\`tsx // Subscribes to entire store - re-renders on any change const state = useStore(); const { count, user } = useStore(); // Component re-renders when anything changes, not just count function Counter() { const { count } = useStore(); return {count}; } \`\`\` ### ✅ Correct \`\`\`tsx // Only re-renders when count changes const count = useStore((state) => state.count); // Named selector const selectCount = (state) => state.count; const count = useStore(selectCount); // Multiple values with shallow import { useShallow } from 'zustand/shallow'; const { count, user } = useStore( useShallow((state) => ({ count: state.count, user: state.user })) ); \`\`\` ## Configuration \`\`\`js // eslint.config.js "uilint/zustand-use-selectors": ["warn", { storePattern: "^use\\\\w*Store$", // Match useXxxStore pattern allowShallow: true, // Allow useShallow without inline selector requireNamedSelectors: false // Allow inline arrow selectors }] \`\`\` `, }); /** * Check if a node is a Zustand store call based on the pattern */ function isZustandStoreCall( callee: TSESTree.Node, storePattern: RegExp ): boolean { if (callee.type === "Identifier") { return storePattern.test(callee.name); } return false; } /** * Check if the first argument is a selector function or reference */ function hasSelector(args: TSESTree.CallExpressionArgument[]): boolean { if (args.length === 0) { return false; } const firstArg = args[0]; // Arrow function: (s) => s.count if (firstArg.type === "ArrowFunctionExpression") { return true; } // Function expression: function(s) { return s.count; } if (firstArg.type === "FunctionExpression") { return true; } // Named selector reference: selectCount if (firstArg.type === "Identifier") { return true; } // Member expression: selectors.count or module.selectCount if (firstArg.type === "MemberExpression") { return true; } // Call expression (might be useShallow or similar) if (firstArg.type === "CallExpression") { return true; } return false; } /** * Check if the selector is wrapped in useShallow */ function isShallowWrapped(args: TSESTree.CallExpressionArgument[]): boolean { if (args.length === 0) { return false; } const firstArg = args[0]; if (firstArg.type === "CallExpression") { if ( firstArg.callee.type === "Identifier" && firstArg.callee.name === "useShallow" ) { return true; } } return false; } /** * Check if the selector is an inline arrow function */ function isInlineSelector(args: TSESTree.CallExpressionArgument[]): boolean { if (args.length === 0) { return false; } const firstArg = args[0]; return ( firstArg.type === "ArrowFunctionExpression" || firstArg.type === "FunctionExpression" ); } /** * Get the store name from the call expression */ function getStoreName(callee: TSESTree.Node): string { if (callee.type === "Identifier") { return callee.name; } return "useStore"; } export default createRule({ name: "zustand-use-selectors", meta: { type: "problem", docs: { description: "Require selector functions when accessing Zustand store state", }, messages: { missingSelector: "Call to '{{storeName}}' is missing a selector. Use '{{storeName}}((state) => state.property)' to prevent unnecessary re-renders.", useSelectorFunction: "Consider using a named selector function instead of an inline arrow for '{{storeName}}'. Example: '{{storeName}}(selectProperty)'", }, schema: [ { type: "object", properties: { storePattern: { type: "string", description: "Regex pattern for store hook names", }, allowShallow: { type: "boolean", description: "Allow useShallow() wrapper", }, requireNamedSelectors: { type: "boolean", description: "Require named selector functions", }, }, additionalProperties: false, }, ], }, defaultOptions: [ { storePattern: "^use\\w*Store$", allowShallow: true, requireNamedSelectors: false, }, ], create(context) { const options = context.options[0] || {}; const storePatternStr = options.storePattern ?? "^use\\w*Store$"; const allowShallow = options.allowShallow ?? true; const requireNamedSelectors = options.requireNamedSelectors ?? false; let storePattern: RegExp; try { storePattern = new RegExp(storePatternStr); } catch { // If invalid regex, use default storePattern = /^use\w*Store$/; } return { CallExpression(node) { // Check if this is a Zustand store call if (!isZustandStoreCall(node.callee, storePattern)) { return; } const storeName = getStoreName(node.callee); // Check for selector if (!hasSelector(node.arguments)) { context.report({ node, messageId: "missingSelector", data: { storeName }, }); return; } // If useShallow is used and allowed, that's fine if (allowShallow && isShallowWrapped(node.arguments)) { return; } // Check for named selectors if required if (requireNamedSelectors && isInlineSelector(node.arguments)) { context.report({ node, messageId: "useSelectorFunction", data: { storeName }, }); } }, }; }, });