React: [eslint-plugin-react-hooks] allow configuring custom hooks as "static"

Created on 24 Sep 2019  路  30Comments  路  Source: facebook/react

Do you want to request a feature or report a bug?

Feature/enhancement

What is the current behavior?

Currently the eslint plugin is unable to understand when the return value of a custom hook is static.

Example:

import React from 'react'

function useToggle(init = false) {
  const [state, setState] = React.useState(init)
  const toggleState = React.useCallback(() => { setState(v => !v) }, [])
  return [state, toggleState]
}

function MyComponent({someProp}) {
  const [enabled, toggleEnabled] = useToggle()

  const handler = React.useCallback(() => {
    toggleEnabled()
    doSomethingWithTheProp(someProp)
  }, [someProp]) // exhaustive-deps warning for toggleEnabled

  return <button onClick={handler}>Do something</button>
}

What is the expected behavior?

I would like to configure eslint-plugin-react-hooks to tell it that toggleEnabled is static and doesn't need to be included in a dependency array. This isn't a huge deal but more of an ergonomic papercut that discourages writing/using custom hooks.

As for how/where to configure it, I would be happy to add something like this to my .eslintrc:

{
  "staticHooks": {
    "useToggle": [false, true],  // first return value is not stable, second is
    "useForm": true,             // entire return value is stable 
  }
}

Then the plugin could have an additional check after these 2 checks that tests for custom names.

Which versions of React, and which browser / OS are affected by this issue? Did this work in previous versions of React?

All versions of eslint-plugin-react-hooks have the same deficiency.

Most helpful comment

bump

All 30 comments

I went ahead and implemented this (see above commit) just to see how it would play out in my own codebase. If anybody else feels like trying it, I've published it to npm under @grncdr/eslint-plugin-react-hooks. You will need to update your eslintrc to reference the scoped plugin name and configure your static hook names:

-  "plugins": ["react-hooks"],
+  "plugins": ["@grncdr/react-hooks"],
   "rules": {
-    "react-hooks/rules-of-hooks": "error",
-    "react-hooks/exhaustive-deps": "warn",
+    "@grncdr/react-hooks/rules-of-hooks": "error",
+    "@grncdr/react-hooks/exhaustive-deps": [
+      "error",
+      {
+        "additionalHooks": "usePromise",
+        "staticHooks": {
+          "useForm": true,
+          "useEntityCache": true,
+          "useItem": [false, true],
+          "useQueryParam": [false, true]
+        }
+      }
+    ],

_(note the hook names above are specific to my app, you probably want your own)_

If anybody from the React team thinks the idea is worth pursuing I'll try to add some tests and make a proper PR.

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contribution.

This seems like a really great addition, would love to see it in react-hooks

@VanTanev have you tried my fork? I've been using it since my last comment and haven't had any issues, but positive experience from others would presumably be interesting to the maintainers.

Any news on this. It's very annoying now because you cannot use reliably this lint rule when you use custom hook, so you have to disable the rule leading to potential dangerous situations

IMHO it would be great if this plugin could detect some common "static" patterns in custom hook, for example if custom hook returns result of useRef()/useCallback(..., [])/useMemo(..., []) etc.

Indeed. Still there may be ambiguous situations and so having the ability to set it up through options could still be needed

Commenting to bump this thread and show my interest. Working on a large codebase with lots of custom hooks means that this would allow us to more reliably use the hooks linter. I understand that the reason they might not want to allow this, is because it could enable people to introduce dangerous bugs into their apps. So maybe it's a feature that should be added with a large disclaimer.

It's pretty likely that the maintainers simply don't want to deal with bug reports that are related to people setting their hooks to static when they actually aren't static. A lot of people will misunderstand what it means to have static hooks.

IMHO it would be great if this plugin could detect some common "static" patterns in custom hook, for example if custom hook returns result of useRef()/useCallback(..., [])/useMemo(..., []) etc.

This is way beyond the scope of what ESLint can do (it would require whole-program taint-tracking) so definitely not going to happen here.

I understand that the reason they might not want to allow this, is because it could enable people to introduce dangerous bugs into their apps. So maybe it's a feature that should be added with a large disclaimer.

It's pretty likely that the maintainers simply don't want to deal with bug reports that are related to people setting their hooks to static when they actually aren't static. A lot of people will misunderstand what it means to have static hooks.

I would totally understand this point of view, but until somebody from the React team replies, I'll keep hoping (and using my fork 馃槈).

@grncdr can you please point me to the source of your folk?

This is really missing for us, because we have hooks like useAxios that always return the same value.

We have faced problems such as:

const axios = useAxios(...);

const requestSomething = useCallback(() => {
      return axios.get(...);
}, []);

ESLint warning:

React Hook useCallback has a missing dependency: 'axios'. Either include it or remove the dependency array.eslint(react-hooks/exhaustive-deps)

I鈥檓 curious about that use case: what is the useAxios hook doing that couldn鈥檛 be accomplished with a normal import?

I鈥檓 curious about that use case: what is the useAxios hook doing that couldn鈥檛 be accomplished with a normal import?

Internally it uses useMemo to create an instance of axios, and also a useEffect that cancels pending requests when the component is unmounted.

Additionally, it configures the baseUrl and automatically injects the authentication token via interceptor.

I would also like to see this behavior, mostly just for setState and useRef.

@douglasjunior don't want to get too off-topic, but you might just wanna have a global/singleton/etc. for that? Seems unnecessary to set the baseUrl and token every time you use the hook, as presumably those values will not change between instances of the hook.

@douglasjunior don't want to get too off-topic, but you might just wanna have a global/singleton/etc. for that? Seems unnecessary to set the baseUrl and token every time you use the hook, as presumably those values will not change between instances of the hook.

The useAxios is configurable, it can receive a custom baseURL, and others configs.

But in the end it makes no difference, the main purpose for us is to cancel pending requests, and make the axios instance private to the component.

Allowing configuration of the dependency argument position would be useful as well.

It is currently hard coded to 0 for additionalHooks:
https://github.com/facebook/react/blob/8b580a89d6dbbde8a3ed69475899addef1751116/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js#L1361

This allows support for hooks that take more than 2 arguments. Eg.:

useImperativeHandle(ref, callback, deps)

I've separately implemented something along the lines of:

rules:
  customHookDeps:
    - error
    - additionalHooks
      - useEventListener: 1
      - useCustomHook: 0
      - useOtherHook

Where the regex syntax can still be supported.

Food for thought: if ESLint is able to leverage any TypeScript information, there could be a way to type-level annotate hooks accordingly.

I think this discussion would benefit from some clarification of what is possible and what is feasible. To that end, I'm writing below the limits on what I would personally propose. I certainly don't know everything about what can be done with ESLint, so if you read this and think "he doesn't know what he's talking about" please correct me!

Limits of this proposal

Couldn't we infer this automatically?

Not using ESLint. Or alternatively, not without making this ESLint plugin extremely complicated. And even if somebody did that work (and the team was willing to maintain it)

Could we annotate the "staticness" of a hook in the source code? (using types and/or comments)

Unfortunately no, the reason is that the ESLint plugin must analyze the locations a variable is _used_ and not where it's declared. At minimum, you would need to annotate a hook every time you import it, since ESLint works on a file-by-file basis.

Could a type checker do this automatically?

After reading the above you might think that Typescript or Flow could be leveraged to tell us when a return value is static. After all, they have the global information about values in separate modules that a linter doesn't.

However, neither of them (as far as I'm aware) let you talk about the type of the _implicit environment_ created by a closure. That is, you can't refer to the variables captured by a function. Without this, you can't propagate information about the closed-over variables to the return type. (If the type systems did have this capability, you theoretically wouldn't need to write the dependency arrays at all)

--

I think it is possible to pass a parameter to "eslint-plugin-react-hooks" in .eslintrc, with the names of the hooks that are static?

Something like what we do with globals?

Sorry if I'm wrong.

I think it is possible to pass a parameter to "eslint-plugin-react-hooks" in .eslintrc, with the names of the hooks that are static?

Yep, that鈥檚 what this issue proposes and what I鈥檝e implemented (see my earlier comments for details). I just wanted to clarify that I think the explicit configuration makes the best possible trade off in terms of implementation complexity.

I think it would be great to have this. Anyone know how to get feedback from a maintainer to see if we can move forward with this?

Suggestion: Follow the same idea as the "camelcase" rule and add "static" option.

https://github.com/eslint/eslint/pull/10783

@douglasjunior could you provide an example of what you mean? I didn鈥檛 understand what you wanted to demonstrate with the PR you linked.

I'm a little late to the party but I think a better approach would be to infer such cases by tracing the retuned values and see if it's something static. If this is not feasible, or doesn't make sense from a performance point of view, maybe we can at least annotate each custom hook to provide such information in a jsdoc comment block like this:

/**
 * Inspired from the format that is suggested in the discussions above:
 * @react-hook: [false, true]
 */
function useToggle(init = false) {
  const [state, setState] = React.useState(init);
  const toggleState = React.useCallback(() => {
    setState(v => !v);
  }, []);
  return [state, toggleState];
}

Advantages of this approach:

  • The information about static parts of the return value is encapsulated in the code. So a third-party library can ship this information with the code without needing the user to adjust their es-lint configuration to make it work.
  • It's still fairly easy to capture this information from the AST without advanced and heavy AST processing.

Disadvantages of this approach:

In the meanwhile that third-party libraries adopt this feature, there is no way to teach eslint-plugin-react-hooks about such static stuff. i.e. the same advantage of being able to put this information in the code can become a disadvantage when you don't have access to the code and it doesn't include this information for any reason.

@alirezamirian do you know if ESlint makes it possible/easy to get the AST information for imported modules? I was under the impression it only worked on a single file at a time.

@grncdr That's a good point. I'm not an eslint expert but I think you are right and we only have Access to Program node corresponding to a single file. The best we can get from the AST in hand is the import statement source. I don't know if there is a utility for resolving AST from an import declaration.

UPDATE: There is a parserPath property on the context, which has a parseForEslint function which can be used to parse other files. So it's technically feasible. But I'm not sure if it's a right approach and it's meant to be used like this.

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

bump

Just another "+1" post, but I'd like to add in that, while there are workarounds, such as using refs or hooks to wrap that kind of logic, it feels unnecessarily boilerplate-y. Having a pragma for ignored values would be so valuable--often devs lazily and dangerously just turn off the whole block and loose safety.

Was this page helpful?
0 / 5 - 0 ratings