diff --git a/README.md b/README.md index 8ee806cf13..57d0bc62cf 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,7 @@ module.exports = [ | [prefer-exact-props](docs/rules/prefer-exact-props.md) | Prefer exact proptype definitions | | | | | | | [prefer-read-only-props](docs/rules/prefer-read-only-props.md) | Enforce that props are read-only | | | 🔧 | | | | [prefer-stateless-function](docs/rules/prefer-stateless-function.md) | Enforce stateless components to be written as a pure function | | | | | | +| [prefer-use-state-lazy-initialization](docs/rules/prefer-use-state-lazy-initialization.md) | Disallow function calls in useState that aren't wrapped in an initializer function | | | | | | | [prop-types](docs/rules/prop-types.md) | Disallow missing props validation in a React component definition | ☑️ | | | | | | [react-in-jsx-scope](docs/rules/react-in-jsx-scope.md) | Disallow missing React when using JSX | ☑️ | 🏃 | | | | | [require-default-props](docs/rules/require-default-props.md) | Enforce a defaultProps definition for every prop that is not a required prop | | | | | | diff --git a/docs/rules/prefer-use-state-lazy-initialization.md b/docs/rules/prefer-use-state-lazy-initialization.md new file mode 100644 index 0000000000..600df93640 --- /dev/null +++ b/docs/rules/prefer-use-state-lazy-initialization.md @@ -0,0 +1,25 @@ +# Disallow function calls in useState that aren't wrapped in an initializer function (`react/prefer-use-state-lazy-initialization`) + + + +A function can be invoked inside a useState call to help create its initial state. However, subsequent renders will still invoke the function while discarding its return value. This is wasteful and can cause performance issues if the function call is expensive. To combat this issue React allows useState calls to use an [initializer function](https://react.dev/reference/react/useState#avoiding-recreating-the-initial-state) which will only be called on the first render. + +## Rule Details + +This rule will warn you about function calls made inside useState calls. + +Examples of **incorrect** code for this rule: + +```js +const [value, setValue] = useState(generateTodos()); +``` + +Examples of **correct** code for this rule: + +```js +const [value, setValue] = useState(() => generateTodos()); +``` + +## Further Reading + +- [Official React documentation on useState](https://react.dev/reference/react/useState) diff --git a/lib/rules/index.js b/lib/rules/index.js index d7142ed9b4..5ffba83c47 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -90,6 +90,7 @@ module.exports = { 'prefer-exact-props': require('./prefer-exact-props'), 'prefer-read-only-props': require('./prefer-read-only-props'), 'prefer-stateless-function': require('./prefer-stateless-function'), + 'prefer-use-state-lazy-initialization': require('./prefer-use-state-lazy-initialization'), 'prop-types': require('./prop-types'), 'react-in-jsx-scope': require('./react-in-jsx-scope'), 'require-default-props': require('./require-default-props'), diff --git a/lib/rules/prefer-use-state-lazy-initialization.js b/lib/rules/prefer-use-state-lazy-initialization.js new file mode 100644 index 0000000000..0c9005e113 --- /dev/null +++ b/lib/rules/prefer-use-state-lazy-initialization.js @@ -0,0 +1,82 @@ +/** + * @fileoverview Detects function calls in useState and suggests using lazy initialization instead. + * @author Patrick Gillespie + */ + +'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, // URL to the documentation page for this rule + }, + fixable: null, // Or `code` or `whitespace` + schema: [], // Add a schema if the rule has options + messages: { + useLazyInitialization: + 'To prevent re-computation, consider using lazy initial state for useState calls that involve function calls. Ex: useState(() => getValue())', + }, + }, + + // rule takes inspiration from https://github.com/facebook/react/issues/26520 + create(context) { + // variables should be defined here + const ALLOW_LIST = Object.freeze(['Boolean', 'String']); + + //---------------------------------------------------------------------- + // Helpers + //---------------------------------------------------------------------- + + // any helper functions should go here or else delete this section + + 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) { + // @ts-ignore + 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/tests/lib/rules/prefer-use-state-lazy-initialization.js b/tests/lib/rules/prefer-use-state-lazy-initialization.js new file mode 100644 index 0000000000..824c583a9d --- /dev/null +++ b/tests/lib/rules/prefer-use-state-lazy-initialization.js @@ -0,0 +1,134 @@ +/** + * @fileoverview Detects function calls in useState and suggests using lazy initialization instead. + * @author Patrick Gillespie + */ + +'use strict'; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester; +const rule = require('../../../lib/rules/prefer-use-state-lazy-initialization'); + +//------------------------------------------------------------------------------ +// 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', + }, + ], + }, + ], +});