facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
229.37k stars 46.97k forks source link

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

Open grncdr opened 5 years ago

grncdr commented 5 years ago

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!

scottrippey commented 2 years ago

@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 commented 2 years ago

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

https://github.com/facebook/react/issues/16873#issuecomment-592574971

grncdr commented 2 years ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

@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 commented 1 year ago

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

jadshep commented 1 year ago

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

zerosheepmoo commented 1 year ago

Any progress?

wogns3623 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 7 months ago

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

ghost commented 5 months ago

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.