react icon indicating copy to clipboard operation
react copied to clipboard

useCallback() invalidates too often in practice

Open gaearon opened this issue 5 years ago • 107 comments

This is related to https://github.com/facebook/react/issues/14092, https://github.com/facebook/react/issues/14066, https://github.com/reactjs/rfcs/issues/83, and some other issues.

The problem is that we often want to avoid invalidating a callback (e.g. to preserve shallow equality below or to avoid re-subscriptions in the effects). But if it depends on props or state, it's likely it'll invalidate too often. See https://github.com/facebook/react/issues/14092#issuecomment-435907249 for current workarounds.

useReducer doesn't suffer from this because the reducer is evaluated directly in the render phase. @sebmarkbage had an idea about giving useCallback similar semantics but it'll likely require complex implementation work. Seems like we'd have to do something like this though.

I'm filing this just to acknowledge the issue exists, and to track further work on this.

gaearon avatar Nov 05 '18 15:11 gaearon

@gaearon thank you for your answer in reactjs/rfcs#83. I've look at sources of useReducer. But I can't understand how it is related to useCallback. What issues has "mutation of ref during rendering"? Can you explain me in brief?

edkalina avatar Nov 07 '18 14:11 edkalina

I've look at sources of useReducer. But I can't understand how it is related to useCallback

useCallback lets you memoize the callback to avoid a different function being passed down every time. But you have to specify everything it depends on in the second array argument. If it's something from props or state, your callback might get invalidated too often.

useReducer doesn't suffer from this issue. The dispatch function it gives you will stay the same between re-renders even if the reducer itself closes over props and state. This works because the reducer runs during the next render (and thus has natural ability to read props and state). It would be nice if useCallback could also do something like this but it's not clear how.

What issues has "mutation of ref during rendering"? Can you explain me in brief?

In concurrent mode (not yet released), it would "remember" the last rendered version, which isn't great if we render different work-in-progress priorities. So it's not "async safe".

gaearon avatar Nov 07 '18 14:11 gaearon

Would be nice if second argument of useCallback was injected as dependencies to callback function.

  function useCallback(cb, deps) => {
    lastDeps = deps; // save current deps and cb deep in somewhere
    lastCb = cb;

    if (!cached) {
      cached = (...args) => lastCb(...lastDeps)(...args); // memoize that forevere
    }

    return cached; // never invalidates
  }

  const myCallback = useCallback(
    (state, props) => (a, b) => a + b + state + props,
    [state, props]
  );

  myCallback(1, 2)

strayiker avatar Nov 10 '18 20:11 strayiker

const useCallback = (fn, args) => {
  const callback = useMemo(() => {
    if (__DEV__) {
      if (fn.length !== args.length) warning(...);
    }
    const callback = () => fn(...callback.args);
    return callback;
  });
  useEffect(() => callback.args = args, [args]);
  return callback;
}

Drawbacks:

It's easy to forget the arguments list, which would result in hard to find bugs. In dev mode it would make sense to check fn.length for the correct length.

It's still possible to forget arguments in the dependencies array, but this applies to other hooks too.

sokra avatar Nov 19 '18 09:11 sokra

Yes, that's the approach from https://github.com/reactjs/rfcs/issues/83 and https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback. We don't want it to be default because it's easier to introduce bugs in concurrent mode this way.

gaearon avatar Nov 19 '18 15:11 gaearon

@sokra An alternate would be:

function useEventCallback(fn) {
  let ref = useRef();
  useLayoutEffect(() => {
    ref.current = fn;
  });
  return useCallback(() => (0, ref.current)(), []);
}

This doesn't require the args like yours has. But again, you can't call this in the render phase and the use of mutation is dicey for concurrent.

sophiebits avatar Nov 19 '18 19:11 sophiebits

@sophiebits That's clever and would have none of the problems with args, etc. It even doesn't require a dependencies list.

One nitpick: return useCallback((...args) => (0, ref.current)(...args), []); to pass along i. e. event argument.

sokra avatar Nov 20 '18 07:11 sokra

@sokra With this you will not be able to access to state and props updates inside a callback.

const [state, setState] = useState(0);

const handleClick = useCallback((event) => {
  console.log(state); // always 0
  setState(s => s + 1);
});

return <button onClick={handleClick} />

So dependencies are required.

function useCallback(fn, deps) {
  const fnRef = useRef(fn);
  const depsRef = useRef(deps);

  useLayoutEffect(() => {
    fnRef.current = fn;
    depsRef.current = deps;
  });

  return useCallback((...args) => (0, ref.current)(...depsRef.current)(...args), []);
}
cons handleClick = useCallback(
  (state) => event => console.log(state), // up-to-date
  [state]
);

strayiker avatar Nov 20 '18 07:11 strayiker

@sokra With this you will not be able to access to state and props updates inside a callback.

I think with @sophiebits' approach this will work. The latest function is always copied into the ref and only a trampoline function is returned. This will make sure that the latest function is called, which has the latest state in context.

sokra avatar Nov 20 '18 08:11 sokra

I recently made a duplicate issue and was asked to check this one. What I proposed there was very similar to @sophiebits' approach, but still looks a bit simpler to me:

function useStatic(cb) {
  const callback = useRef(cb)
  callback.current = cb

  const mem = useRef((...args) => callback.current(...args))
  return mem.current
  
  // We could, of course still, use the useCallback hook instead of a second reference.
  // return useCallback((...args) => callback.current(...args), [])
  // Although I think that the one above is better since it avoids the need to compare anything at all.
}

This way it is guaranteed to update where the hook is called since it does not directly use any side effect and instead it only updates a reference. It seems to me that it should be callable during the render phase and should not be dicey with concurrent mode (unless references don't meet these two conditions). Wouldn't this approach be a little better or am I missing something?

muqg avatar Nov 21 '18 15:11 muqg

@muqg In concurrent mode, last render doesn't necessarily mean "latest committed state". So a low-priority render with new props or state would overwrite a reference used by current event handler.

gaearon avatar Nov 21 '18 20:11 gaearon

If I understand the problem correctly...

What if useCallback will return a special callable object with two different states of an our callback function? The first is a current callback, that will changes on each render. The second is a commited callback, that changes within a commit phase. By default call to that object will trigger the current value, so it can be used in render phase. But internally, React will pass the commited value to the dom, which prevents the callback from being modified until the next commit.

function Callback(cb) {
  function callback(...args) {
    return callback.current(...args);
  }

  callback.commited = cb;
  callback.current = cb;
  callback.SPECIAL_MARKER_FOR_REACT = true;

  return callback;
}

function useCallback(cb) {
  const callback = useMemo(() => new Callback(cb), []);

  callback.current = cb;
  
  useLayoutEffect(() => {
    callback.commited = cb;
  });

  return callback;
}
function Component(counter) {
  const handler = useCallback(() => {
    console.log(counter);
  });

  handler(); // call to handler.current

  // pass handler.commited to the dom
  return <button onClick={handler} />
}

strayiker avatar Nov 21 '18 23:11 strayiker

I don't think there is a point in saving the "current", if you want to call it during rendering, just save it in advance out of the hook:

const handler = () => {/* do something*/};
const callback = useCallback(handler);
// I can call the current:
handler();

I personally don't see any benefits of the current useCallback implementation over the proposed useEventCallback, will it become the new implementation? Also, can it warn when the callback is called during render in development mode?

Volune avatar Nov 22 '18 03:11 Volune

Concurrent mode can produce two different representations of a component (the first one is that commited to the dom and the second one is that in memory). This representations should behaves accordingly with their props and state.

useEventCallback by @sophiebits mutates ref.current after all dom mutations is completed, so the current (in-memory) component can't use the newest callback until the commit is done.

@muqg proposal mutate the callback on each render, so the commited component will lose the reference to the old callback.

The point of my proposal in the passing a separated callback reference, that will changes in commit phase, to the dom, while the in-memory (not commited) representation of a component can use the latest version of that callback.

const handler = () => {/* do something*/};
const callback = useCallback(handler);

In this case, you wont pass down the handler to other components because it always changes. You will pass the callback, but will face again the concurrent mode problem.

strayiker avatar Nov 22 '18 08:11 strayiker

Hi. According to @sophiebits useEventCallback implementation why is it function uses useLayoutEffect and not useEffect for ref updating? And is it normal due to current limitations use useEventCallback for all internal regular functions with some logic (which wants be memoized for using in expensive pure components tree or/and has closured variables from outer hook function) inside custom hook?

Voronar avatar Dec 14 '18 06:12 Voronar

How/why is it that variables are dereferenced within useEffect?

Whether or not the effect is called again based on changes to state/reducer/etc (useEffect's second param), shouldn't have any implication on those variable's references within useEffect, right?

This behavior seems unexpected and having to leverage "escape hatches" just feels broken to me.

jonnyasmar avatar Dec 29 '18 23:12 jonnyasmar

I have a problem with converting this kind of thing to use functional components and the useCallback hook...

export class TestForm extends React.Component {

    onChangeField = (name,value) => {
        this.setState({ [name]: value });
    };

    render () {
        const state = this.state;
        return (
            <>
                <PickField name="gender"      value={state.gender}      onChangeField={this.onChangeField} />
                <TextField name="firstName"   value={state.firstName}   onChangeField={this.onChangeField} />
                <TextField name="lastName"    value={state.lastName}    onChangeField={this.onChangeField} />
                <DateField name="dateOfBirth" value={state.dateOfBirth} onChangeField={this.onChangeField} />
            </>
        );
    }

The PickField, TextField and DateField components can be implemented with React.PureComponent or React.memo(...). Basically they just display an input field and a label - they have their own onChange handler which calls the onChangeField prop passed in. They only redraw if their specific value changes

onChangeField as above works just fine - but if I try this using a functional component for TestForm and useCallback for onChangeField I just can't get it to 'not' redraw everything on a single field change

Bazzer588 avatar Jan 05 '19 13:01 Bazzer588

@Bazzer588 Are you using React.memo on your functional component? What do your attempts using hooks/functional look like? Your problem may or may not be related to this issue; it's hard to tell without seeing your code.

jonnyasmar avatar Jan 05 '19 22:01 jonnyasmar

Here's the full code - just drop a <TestForm/> or a <HookTestForm/> somewhere on a page

Using a React.Component, only the field being editted does a full render

import React from 'react';
import TextField from "./TextField";

export default class TestForm extends React.Component {

    onChangeField = (name,value) => {
        this.setState({ [name]: value });
    };

    render () {
        const state = this.state || {};
        return (
            <>
                <TextField name="gender"      value={state.gender}      onChangeField={this.onChangeField} />
                <TextField name="firstName"   value={state.firstName}   onChangeField={this.onChangeField} />
                <TextField name="lastName"    value={state.lastName}    onChangeField={this.onChangeField} />
                <TextField name="dateOfBirth" value={state.dateOfBirth} onChangeField={this.onChangeField} />
            </>
        );
    }
}

Using Hooks, all the child elements are re-rendered every time - presumably as onChangeField changes every time the state data changes. Is there some way I can implement onChangeField so it behaves like the above example?

import React, {useState, useCallback} from 'react';
import TextField from "./TextField";

export default React.memo( () => {

    const [data, changeData] = useState({});

    const onChangeField = useCallback((name,value) => {
        changeData({ ...data, [name]: value });
    }, [data] );

    return (
        <>
            <TextField name="gender"      value={data.gender}      onChangeField={onChangeField} />
            <TextField name="firstName"   value={data.firstName}   onChangeField={onChangeField} />
            <TextField name="lastName"    value={data.lastName}    onChangeField={onChangeField} />
            <TextField name="dateOfBirth" value={data.dateOfBirth} onChangeField={onChangeField} />
        </>
    );
});

This is my <TextField> component - you can see when it does a full render from the console or with the React dev tools

import React from 'react';

export default React.memo( ({ name, value, onChangeField }) => {

    console.log('RENDER TEXT FIELD',name,value);

    return (
        <div className="form-field">
            <label htmlFor={name}>{name}</label>
            <input
                type="text"
                onChange={ (ev) => onChangeField( name, ev.target.value ) }
                value={value || ''}
            />
        </div>
    );
});

Bazzer588 avatar Jan 06 '19 19:01 Bazzer588

@Bazzer588 I think its due to the object value kept in state under the variable name of data. I don't know why but objects in state always invalidate memoization and thus your callback onChangeField is a new on on each render thus breaking the memoization of the components you're passing it to.

I've had a similar issue like you and noticed this as being its cause. I have no idea why the object in state does not keep its reference when it has not been explicitly set to a new object.

muqg avatar Jan 06 '19 20:01 muqg

Yes my problem is that the in first example (using React.Component) I can create a onChangeField callback which is bound to this, and never changes during renders

Using the new hook methods I can't seem to replicate the way the existing functionality works,

On the project I'm working on we often do this to have a hierachy of components and state:

    onChangeField = (fieldName,fieldValue) => {
        const newValue = { ...this.props.value, [fieldName]: fieldValue };
        this.props.onChangeField( this.props.name, newValue );
    };

So it passes props down the tree (ie value {}) And uses the callback to send new values up the tree

Bazzer588 avatar Jan 06 '19 20:01 Bazzer588

Using the new hook methods I can't seem to replicate the way the existing functionality works,

Use useReducer.

dantman avatar Jan 06 '19 20:01 dantman

@Bazzer588 The data var your passing to the second parameter of useCallback is going to invalidate every time. useCallback doesn't do a deep comparison. You need to pass in a flat array for it to properly memoize.

jonnyasmar avatar Jan 06 '19 20:01 jonnyasmar

Yes I've tried similar with useReducer and passing dispatch down to the child components - this does seem to work as you get the same dispatch method so it doesn't force a re-render

If there's no way to make callback functions work as they did before I guess that's the way we'll have to go

Bazzer588 avatar Jan 06 '19 20:01 Bazzer588

@Bazzer588 The general recommendation is to useReducer.

That said, your particular example can be solved like this but it is pretty unusual to not depend on any props so this doesn't always work:

    const onChangeField = useCallback((name, value) => {
        changeData(oldData => ({ ...oldData, [name]: value }));
    }, []);

sebmarkbage avatar Jan 06 '19 21:01 sebmarkbage

Thanks @sebmarkbage - didn't realise you could pass a function to changeData (As in const [data, changeData] = useState({}); ) However now I'm worried - in what case would this doesn't always work ?

Bazzer588 avatar Jan 06 '19 21:01 Bazzer588

@gaearon, is this issue going to be solved before hooks release?

doasync avatar Jan 26 '19 20:01 doasync

Workarounds above (especially relying more on useReducer) seem sufficient for most cases. There are cases when they’re not, but we’ll likely revisit this again in a few months.

gaearon avatar Jan 26 '19 21:01 gaearon

It's very, very different behaviour

With a ES6 class I can pass onChange={this.handleChange} to the child component and it does not redraw every time (because it sends the same handleChange method every time)

It's frustrating that dispatch works independent of state (I mean the dispatch method does not change even if the underlying state does) but useCallback does not (if your callback needs to do something with state that is)

I see a lot of code where the whole form redraws when the use types into a field

A lot of devs don't understand this until users complain 'it's slow'

Bazzer588 avatar Jan 27 '19 00:01 Bazzer588

@Bazzer588 The general recommendation is to useReducer.

That said, your particular example can be solved like this but it is pretty unusual to not depend on any props so this doesn't always work:

    const onChangeField = useCallback((name, value) => {
        changeData(oldData => ({ ...oldData, [name]: value }));
    }, []);

@sebmarkbage I tried the approach above of using the oldData argument, but it didn't help me to avoid re-renders of the children components (that's why I was using useCallback)

So I tried using useReducer and use dispatch inside the useCallback but all the children are still re-rendering.

Moreover, I'm passing the state to a callback after changing it, and it is always the previous state (off by one).

Can someone take a look and let me know what the recommended approach is?

Thanks in advance!

carlosagsmendes avatar Jan 27 '19 01:01 carlosagsmendes