diff --git a/packages/eslint-plugin-react-hooks/README.md b/packages/eslint-plugin-react-hooks/README.md index e3b7d40431ea2..4a01036d76508 100644 --- a/packages/eslint-plugin-react-hooks/README.md +++ b/packages/eslint-plugin-react-hooks/README.md @@ -42,7 +42,8 @@ If you want more fine-grained configuration, you can instead add a snippet like "rules": { // ... "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn" + "react-hooks/exhaustive-deps": "warn", + "react-hooks/prefer-use-state-lazy-initialization": "warn" } } ``` diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulePreferUseStateLazyInitialization-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulePreferUseStateLazyInitialization-test.js new file mode 100644 index 0000000000000..01db4d5fd1bce --- /dev/null +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulePreferUseStateLazyInitialization-test.js @@ -0,0 +1,129 @@ +'use strict'; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester; +const rule = require('../src/PreferUseStateLazyInitialization'); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester(); +ruleTester.run('prefer-use-state-lazy-initialization', rule, { + valid: [ + // give me some code that won't trigger a warning + 'useState()', + 'useState("")', + 'useState(true)', + 'useState(false)', + 'useState(null)', + 'useState(undefined)', + 'useState(1)', + 'useState("test")', + 'useState(value)', + 'useState(object.value)', + 'useState(1 || 2)', + 'useState(1 || 2 || 3 < 4)', + 'useState(1 && 2)', + 'useState(1 < 2)', + 'useState(1 < 2 ? 3 : 4)', + 'useState(1 == 2 ? 3 : 4)', + 'useState(1 === 2 ? 3 : 4)', + ], + + invalid: [ + { + code: 'useState(1 || getValue())', + errors: [ + { + message: rule.meta.messages.useLazyInitialization, + type: 'CallExpression', + }, + ], + }, + { + code: 'useState(2 < getValue())', + errors: [ + { + message: rule.meta.messages.useLazyInitialization, + type: 'CallExpression', + }, + ], + }, + { + code: 'useState(getValue())', + errors: [ + { + message: rule.meta.messages.useLazyInitialization, + type: 'CallExpression', + }, + ], + }, + { + code: 'useState(getValue(1, 2, 3))', + errors: [ + { + message: rule.meta.messages.useLazyInitialization, + type: 'CallExpression', + }, + ], + }, + { + code: 'useState(a ? b : c())', + errors: [ + { + message: rule.meta.messages.useLazyInitialization, + type: 'CallExpression', + }, + ], + }, + { + code: 'useState(a() ? b : c)', + errors: [ + { + message: rule.meta.messages.useLazyInitialization, + type: 'CallExpression', + }, + ], + }, + { + code: 'useState(a ? (b ? b1() : b2) : c)', + errors: [ + { + message: rule.meta.messages.useLazyInitialization, + type: 'CallExpression', + }, + ], + }, + { + code: 'useState(a() && b)', + errors: [ + { + message: rule.meta.messages.useLazyInitialization, + type: 'CallExpression', + }, + ], + }, + { + code: 'useState(a && b())', + errors: [ + { + message: rule.meta.messages.useLazyInitialization, + type: 'CallExpression', + }, + ], + }, + { + code: 'useState(a() && b())', + errors: [ + { + message: rule.meta.messages.useLazyInitialization, + type: 'CallExpression', + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin-react-hooks/src/PreferUseStateLazyInitialization.js b/packages/eslint-plugin-react-hooks/src/PreferUseStateLazyInitialization.js new file mode 100644 index 0000000000000..8c0415ea0e4e4 --- /dev/null +++ b/packages/eslint-plugin-react-hooks/src/PreferUseStateLazyInitialization.js @@ -0,0 +1,72 @@ +'use strict'; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + "Disallow function calls in useState that aren't wrapped in an initializer function", + recommended: false, + url: null, + }, + fixable: null, + schema: [], + messages: { + useLazyInitialization: + 'To prevent re-computation, consider using lazy initial state for useState calls that involve function calls. Ex: useState(() => getValue())', + }, + }, + + create(context) { + const ALLOW_LIST = Object.freeze(['Boolean', 'String']); + + //---------------------------------------------------------------------- + // Helpers + //---------------------------------------------------------------------- + + const hasFunctionCall = node => { + if ( + node.type === 'CallExpression' && + ALLOW_LIST.indexOf(node.callee.name) === -1 + ) { + return true; + } + if (node.type === 'ConditionalExpression') { + return ( + hasFunctionCall(node.test) || + hasFunctionCall(node.consequent) || + hasFunctionCall(node.alternate) + ); + } + if ( + node.type === 'LogicalExpression' || + node.type === 'BinaryExpression' + ) { + return hasFunctionCall(node.left) || hasFunctionCall(node.right); + } + return false; + }; + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + return { + CallExpression(node) { + if (node.callee && node.callee.name === 'useState') { + if (node.arguments.length > 0) { + const useStateInput = node.arguments[0]; + if (hasFunctionCall(useStateInput)) { + context.report({node, messageId: 'useLazyInitialization'}); + } + } + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin-react-hooks/src/index.js b/packages/eslint-plugin-react-hooks/src/index.js index d8ff02e7b144f..fb8f72a81ab7f 100644 --- a/packages/eslint-plugin-react-hooks/src/index.js +++ b/packages/eslint-plugin-react-hooks/src/index.js @@ -9,6 +9,7 @@ import RulesOfHooks from './RulesOfHooks'; import ExhaustiveDeps from './ExhaustiveDeps'; +import PreferUseStateLazyInitialization from './PreferUseStateLazyInitialization'; export const configs = { recommended: { @@ -23,4 +24,5 @@ export const configs = { export const rules = { 'rules-of-hooks': RulesOfHooks, 'exhaustive-deps': ExhaustiveDeps, + 'prefer-use-state-lazy-initialization': PreferUseStateLazyInitialization, };