From ebb739a0fe99a2ee77055870bfda9f67a2691374 Mon Sep 17 00:00:00 2001 From: patorjk Date: Mon, 15 May 2023 23:53:05 -0400 Subject: [PATCH 1/2] Initial work for useState lazy initialization rule --- .../prefer-use-state-lazy-initialization.md | 25 ++++ lib/rules/index.js | 1 + .../prefer-use-state-lazy-initialization.js | 82 +++++++++++ .../prefer-use-state-lazy-initialization.js | 134 ++++++++++++++++++ 4 files changed, 242 insertions(+) create mode 100644 docs/rules/prefer-use-state-lazy-initialization.md create mode 100644 lib/rules/prefer-use-state-lazy-initialization.js create mode 100644 tests/lib/rules/prefer-use-state-lazy-initialization.js 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', + }, + ], + }, + ], +}); From 70ce1a2dbf8891f8af14ea37398e7769788ab9f8 Mon Sep 17 00:00:00 2001 From: patorjk Date: Mon, 15 May 2023 23:53:57 -0400 Subject: [PATCH 2/2] Updated docs --- README.md | 1 + 1 file changed, 1 insertion(+) 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 | | | | | |