Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[eslint-plugin-react-hooks] allow configuring custom hooks as "static" #16873

Open
grncdr opened this issue Sep 24, 2019 · 67 comments
Open

[eslint-plugin-react-hooks] allow configuring custom hooks as "static" #16873

grncdr opened this issue Sep 24, 2019 · 67 comments

Comments

@grncdr
Copy link

grncdr commented Sep 24, 2019

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.

Please read my first comment below and try my fork if you are interested in this feature!

@grncdr grncdr changed the title [eslint-plugin-react-hooks] allow configuring custom hooks as "stable" [eslint-plugin-react-hooks] allow configuring custom hooks as "static" Sep 29, 2019
grncdr added a commit to grncdr/react that referenced this issue Sep 29, 2019
@grncdr
Copy link
Author

grncdr commented Sep 29, 2019

I went ahead and implemented this 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.

To try out my fork:

Install it

Update your package.json to install @grncdr/eslint-plugin-react-hooks:

-    "eslint-plugin-react-hooks": "...",
+    "@grncdr/eslint-plugin-react-hooks": "5.0.0-p30d423311.0"

Update your .eslintrc

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,
+          "useRouter": true,
+          "useEntityCache": true,
+          "useItem": [false, true],
+          "useQueryParam": [false, true],
+          "useSomeQuery": {
+            "reload": true,
+            "data": false,
+            "error": false,
+            "isLoading": false
+          }
+        }
+      }
+    ],

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

The staticHooks config maps a hook name to the "shape" of return values that are static, as described above in the original issue. Given the above examples...

"useRouter": true means the return value of useRouter is considered stable.

"useQueryParam": [false, true] this defines a useState-like hook that returns an array of [value, setQueryParam]. The value is not stable, but the setter is.

"useSomeQuery": { ... }" this defines a react-query-like hook that returns a complex object. That object includes a reload callback that is stable, but the data/error/isLoading properties are not.


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

@stale
Copy link

stale bot commented Jan 9, 2020

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.

@stale stale bot added the Resolution: Stale Automatically closed due to inactivity label Jan 9, 2020
@VanTanev
Copy link

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

@stale stale bot removed the Resolution: Stale Automatically closed due to inactivity label Jan 16, 2020
@grncdr
Copy link
Author

grncdr commented Jan 19, 2020

@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.

@ramiel
Copy link

ramiel commented Jan 23, 2020

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

@kravets-levko
Copy link

kravets-levko commented Jan 23, 2020

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.

@ramiel
Copy link

ramiel commented Jan 23, 2020

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

@AriTheElk
Copy link

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.

@grncdr
Copy link
Author

grncdr commented Jan 28, 2020

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 😉).

@ksjogo
Copy link

ksjogo commented Feb 25, 2020

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

@grncdr
Copy link
Author

grncdr commented Feb 28, 2020

@ksjogo sure, my changes are in this branch: https://github.com/grncdr/react/tree/eslint-plugin-react-hooks-static-hooks-config

You can use it by installing @grncdr/eslint-plugin-react-hooks and updating the config as described in #16873 (comment)

@douglasjunior
Copy link

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)

@grncdr
Copy link
Author

grncdr commented Mar 8, 2020

I’m curious about that use case: what is the useAxios hook doing that couldn’t be accomplished with a normal import?

@douglasjunior
Copy link

I’m curious about that use case: what is the useAxios hook doing that couldn’t 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.

@acnebs
Copy link

acnebs commented Mar 19, 2020

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
Copy link

@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.

@squirly
Copy link

squirly commented Apr 8, 2020

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

It is currently hard coded to 0 for additionalHooks:

return options.additionalHooks.test(name) ? 0 : -1;

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.

@quicksnap
Copy link

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

@grncdr
Copy link
Author

grncdr commented Apr 23, 2020

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. Even if somebody did that work there's no indication the React team at Facebook would want 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)

--

@douglasjunior
Copy link

douglasjunior commented Apr 23, 2020

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.

@grncdr
Copy link
Author

grncdr commented Apr 24, 2020

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’s what this issue proposes and what I’ve 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.

@quicksnap
Copy link

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?

@douglasjunior
Copy link

douglasjunior commented May 7, 2020

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

eslint/eslint#10783

@grncdr
Copy link
Author

grncdr commented May 15, 2020

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

@alirezamirian
Copy link

alirezamirian commented Jun 2, 2020

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.

@grncdr
Copy link
Author

grncdr commented Jun 2, 2020

@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.

@OrkhanAlikhanov
Copy link

3 years almost but no progress on such a simple but useful addition. For those not wanting to use a fork, we can utilize patch-package to maintain our own patch locally. I created a patch out of @grncdr's work in his fork.

@Tsourdox
Copy link

Tsourdox commented Sep 7, 2022

This would be a great addition for all the written custom hooks.

@scottrippey
Copy link

@grncdr Do you have your fork published anywhere? I'd love to see how you handled it, and it'd be a great to dust it off and get an official PR here.

@grncdr
Copy link
Author

grncdr commented Sep 7, 2022

@grncdr Do you have your fork published anywhere? I'd love to see how you handled it, and it'd be a great to dust it off and get an official PR here.

#16873 (comment)

grncdr added a commit to grncdr/react that referenced this issue Sep 23, 2022
@grncdr
Copy link
Author

grncdr commented Sep 23, 2022

For those who are interested, I've published a new version 5.0.0-p30d423311.0 that is rebased on top of the latest upstream changes.

The main improvement is fixing compatibility with ESLint 8. Thanks a lot to @luketanner for not only bringing this to my attention, but opening a PR to fix it. 🥇

@xeger
Copy link

xeger commented Nov 22, 2022

I'd very much like to see the @grncdr enhancement merged into the trunk -- ideally with some configuration so that one could generalize this pattern and declare a whole family of custom hooks that guarantee stable values. (There are plenty of ways to wrap useState, useEvent et al!)

Until then, I'll look into using your fork; thanks for sharing.

@deiga
Copy link

deiga commented Dec 2, 2022

Could someone enlighten me what the downside is of adding a static dependency to the dependency array of a hook? The way I understand it, one can add the static dependency to the array and it shouldn't have any effect and it will appease the linter rule

@OrkhanAlikhanov
Copy link

@deiga There isn't a functional difference, but it is also unnecessary and leads to confusion for a moment. The linter allows omitting internally known list of hooks from deps array and we would like to be able to configure that list.

@vpstuart
Copy link

vpstuart commented Dec 9, 2022

+1 in favour of this configuration option, needed for:

  • useImmer from 'use-immer', which is essentially a layer on top of useState
  • useNavigate from react-router
  • Lots of custom hooks

@jadshep
Copy link

jadshep commented May 19, 2023

Bump. This is desperately needed. This issue has been a massive blocker to wide use of custom hooks.

@zerosheepmoo
Copy link

Any progress?

@wogns3623
Copy link

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.

I wrote an eslint plugin based on a forked version of @grncdr with some ideas from @kravets-levko.
https://github.com/wogns3623/eslint-plugin-better-exhaustive-deps

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.

The static value tracking I wrote was based on existing code from the React team, so I don't think it will have the performance issues @grncdr was worried about - feel free to correct me if I'm wrong!

P.S. English is not my native language, so the sentences may seem strange. Please let me know of any strange things and I'll fix them!

@grncdr
Copy link
Author

grncdr commented Oct 25, 2023

I wrote an eslint plugin based on a forked version of @grncdr with some ideas from @kravets-levko. https://github.com/wogns3623/eslint-plugin-better-exhaustive-deps

Nice, and it's probably a good idea to write it as a separate plugin instead of a fork of the facebook one, since it seems there's no interest from the facebook team.

I took a quick look at the repo but since it starts with an initial commit that already has the relevant changes I'm not really sure what you did differently. Do I understand correctly that this can handle situations like the following?

// file: use-toggle.js
import {useCallback, useState} from 'react'

export function useToggle(initialState = false) {
  const [state, setState] = useState(initialState)
  const toggle = useCallback(() => setState(x => !x), [])
  return [state, toggle, useState]
}

// file: some-component.js
import { useCallback } from 'react'
import { useToggle } from './use-toggle.js'

export function SomeComponent() {
  const [enabled, toggleEnabled] = useToggle()
  
  // ignore how contrived this callback is, the important thing is that
  // `toggleEnabled` is detected as static.
  const toggleAndLog = useCallback(() => {
    toggleEnabled()
    
    console.log('toggled state')
    
  }, [])
  
  return <div>
    <div>{enabled ? 'Enabled' : 'Disabled'}</div>
    <button onClick={toggleAndLog}>Toggle</button>
  </div>
}

That is, does it propagate "staticness" across files?


In any case, I'm hardly doing anything with React these days, so I'd be very happy to pass the torch and recommend your plugin over my forked one.

@wogns3623
Copy link

wogns3623 commented Oct 26, 2023

That is, does it propagate "staticness" across files?

No. The static value check option I created, checkMemoizedVariableIsStatic, was created to check if the return value of useMemo or useCallback is static within a single component. It still doesn't automatically infer whether the return value of a custom hook written outside of the component is static, and you still have to manually write the staticHooks option for that.

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.

Certainly I misunderstood the meaning you discussed due to my poor English. I agree with your comment that it's hard to automatically infer the staticness of the return value of a hook written outside of a component (though I'll try to find a way).

I added the checkMemoizedVariableIsStatic function because I thought there was no way to recognize the return value of form like useCallback(..., []) as static, even though useCallback/useMemo are the default hooks provided by react.

@wogns3623
Copy link

wogns3623 commented Oct 26, 2023

For visual explanation, my plugin with checkMemoizedVariableIsStatic: true can handle the following cases.

import { useCallback } from "react";

export function SomeComponent() {
  const [enabled, setEnabled] = useState(false);
  const toggleEnabled = useCallback(() => setEnabled((x) => !x), []);

  // `toggleEnabled` is detected as static.
  const toggleAndLog = useCallback(() => {
    toggleEnabled();

    console.log("toggled state");
  }, []);

  return (
    <div>
      <div>{enabled ? "Enabled" : "Disabled"}</div>
      <button onClick={toggleAndLog}>Toggle</button>
    </div>
  );
}

@grncdr
Copy link
Author

grncdr commented Oct 26, 2023

That still seems like a solid improvement. Does it take into account the dependencies array of useCallback so that useCallback(() => {...}, [someProp]) is not considered static?

@wogns3623
Copy link

wogns3623 commented Oct 26, 2023

Yes, of course! If any of the values in the dependency array are non-static (in the example above, if someProp is non-static), then the return value will be non-static. If the values are all static, the return value will also be treated as static (although there's no reason to do this).

import { useEffect, useCallback, useMemo } from 'react';

export function SomeComponent({ nonStaticValue }: { nonStaticValue: string }) {
  const staticValue = 'some static value';

  // `staticCallback` is treated as static.
  const staticCallback = useCallback(() => {
    console.log(staticValue);
    // if dependency array has static value(even if it is not necessary),
    // return value of useCallback treated as static.
  }, [staticValue]);

  // if any of the dependencies are not static, return value of useCallback or useMemo treated as non-static.
  const someMemoizedValue = useMemo(() => {
    return staticValue + nonStaticValue;
  }, [nonStaticValue]);

  useEffect(() => {
    console.log(someMemoizedValue);
  }, []);
  // ^^ lint error: React Hook useEffect has a missing dependency: 'someMemoizedValue'.

  useEffect(() => {
    staticCallback();
  }, []); // no lint error

  // ...
}

I took a quick look at the repo but since it starts with an initial commit that already has the relevant changes I'm not really sure what you did differently. Do I understand correctly that this can handle situations like the following?

If you're curious about the changes to the features I've added, check out this commit!

@huynhducduy
Copy link

Any update on this? because biome already implemented that biomejs/biome#1979 . Guess I gotta think about switch to biome then :/

@ghost
Copy link

ghost commented Jun 6, 2024

Here was my solution for today - getting out of the hooks business altogether for things that are "configured once" during app initialization such as maybe an axios instance or whatever. Instead of using a hook to get these things into my components, I'm now using a main module to configure first and then do an async import(s) for components that need the configured things.

The asyncly imported components can then just import the pre-configured things they need at module level instead of using a hook at all.

I've tested this with my own app and it works great. I think it will be even better than involving hooks for that sort of thing, so I hth.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.