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

Lazy useRef instance variables #14490

Closed
Diokuz opened this issue Dec 24, 2018 · 24 comments
Closed

Lazy useRef instance variables #14490

Diokuz opened this issue Dec 24, 2018 · 24 comments

Comments

@Diokuz
Copy link

Diokuz commented Dec 24, 2018

I want to save a class instance, not just plain object, with useRef hook. And, I dont want to construct it each time hook function is invoked.

Is it possible to do that?

My thoughts were:

const instanceRef = useRef(() => new Instance())

But useRef does not accept the initial value as a function (useState and useReducer could do that).
Will that change in alpha 3?

@nikilok
Copy link

nikilok commented Dec 24, 2018

@Diokuz useRef does take in an initial state.

https://reactjs.org/docs/hooks-reference.html#useref

In the above link, they just pass null as the initial value.

@Diokuz
Copy link
Author

Diokuz commented Dec 24, 2018

@nikilok yes, but the question was about how to create lazy initial value (create it only once).

My current solution is:

const [refInitialValue] = useState(() => new Instance)
const instanceRef = useRef(refInitialValue)

but that is a bit overhead.

@TrySound
Copy link
Contributor

@Diokuz Just check its existence

const instance = React.useRef(null)
if (instance.current == null) {
  instance.current = {
    // whatever you need
  }
}

@jamiewinder
Copy link

I created this custom hook to help with this limitation, someone might find it useful:

import { useRef } from 'react';

const SENTINEL = {};

export function useRefFn<T>(init: () => T) {
    const ref = useRef<T | typeof SENTINEL>(SENTINEL);
    if (ref.current === SENTINEL) {
        ref.current = init();
    }
    return ref as React.MutableRefObject<T>;
}

@Jessidhia
Copy link
Contributor

It happens to work in this case because your function is generic and so T is "like {}", but you need to use a Symbol for sentinels to create a unique symbol type. Using an empty object's type as an input makes it nearly the same as any but without null | undefined.

@jamiewinder
Copy link

Sorry, I'm not following. It would work for null, undefined, or any value as far as I can see.

I use an empty object as the sentinel as it makes it unique and distinct for the === check which sets the ref to the real initial value on the first invocation. It'd work it the sentinel were a symbol too, but I don't think it makes a difference one way or the other?

@jamiewinder
Copy link

Late realisation: doesn't useMemo (with an empty array as the second parameter) do exactly this?

@gaearon
Copy link
Collaborator

gaearon commented Jan 16, 2019

useMemo with [] is not recommended for this use case. In the future we will likely have use cases where we drop useMemo values — to free memory or to reduce how much we retain with e.g. virtual scrolling for hidden items. You shouldn't rely on useMemo retaining a value.

We talked more about this and settled on this pattern as the recommendation for expensive objects:

function Foo() {
  const instanceRef = useRef(null)

  function getInstance() {
    let instance = instanceRef.current;
    if (instance !== null) {
      return instance;
    }
    // Lazy init
    let newInstance = new Instance()
    instanceRef.current = newInstance;
    return newInstance;
  }

  // Whenever you need it...
  const instance = getInstance();
  // ...
}

Note how you can declare getInstance() as : Instance — it's easy to guarantee it's never null.

So you both get type safety and delay creation until the object is actually necessary (which may happen conditionally during rendering, or during an effect, etc).

Hope this helps!

@gaearon
Copy link
Collaborator

gaearon commented Jan 17, 2019

@ghengeveld
Copy link
Contributor

How about:

const [myRef] = useState(() => ({ current: calculateInitialValue() }))

Is there any reason not to "abuse" useState like that?

@mgtitimoli
Copy link

mgtitimoli commented Apr 26, 2019

// @flow

import {useRef} from "react";

type HookRef<Value> = {|
  current: Value
|};

const noValue = Symbol("lazyRef.noValue");

const useLazyRef = <Value>(getInitialValue: () => Value): HookRef<Value> => {
  const lazyRef = useRef(noValue);

  if (lazyRef.current === noValue) {
    lazyRef.current = getInitialValue();
  }

  return lazyRef;
};

export default useLazyRef;

@wearhere
Copy link

wearhere commented Dec 3, 2019

I second @ghengeveld's question above about "abusing" setState for this purpose especially given Dan's comment here saying that useRef is basically useState anyway. The official recommendation seems much less concise. @gaearon, could you clarify?

@ghengeveld
Copy link
Contributor

The benefit of Dan's approach is that the instance is created once, at the point where it's going to be used (if at all), while my solution just instantiates/creates it immediately (also only once). I think my pattern is fine if you know for certain you are going to need the value. Unless there is a reason to avoid this trick, like there is for useMemo?

@peterjcaulfield
Copy link

peterjcaulfield commented Jun 2, 2020

You can also just pass a function that mutates a ref to useMemo without being at risk of changing semantics WRT retained values:

const initializeRef = (ref: React.MutableRefObject<any>) => {
     if (!ref.current) {
         ref.current = init();
     }
}

// in your component
const ref = useRef(null)
useMemo(() => {
    initializeRef(ref)
}, [])

The handy thing about this is that you can also re-run initialisation of the ref if needed by supplying deps.

@alamothe
Copy link

Usually we want to change ref.current (isn't this exactly why refs are for?), so I don't see much practical value in the getInstance approach.

The useState solution is very natural and exactly what's needed.

But ideally, React should just improve useRef to accept a lazy init.

@alii
Copy link

alii commented Mar 11, 2022

fwiw, published a hook for this under the npm package alistair and can be used as import {useLazyRef} from 'alistair/hooks';

src: https://github.com/alii/alistair/blob/master/src/hooks/use-lazy-ref.ts

@panta82
Copy link

panta82 commented Apr 7, 2022

Is this still the recommended approach?

I am getting bugs with react-refresh hot updates, when I'm using lazy ref init with a useEffect() that clears the data.

Eg.

if (!editorRef.current) {
  editorRef.current = new Editor();
}
useEffect(() => {
  return () => {
    if (editorRef.current) {
      editorRef.current.destroy();
      editorRef.current = null;
    }
  };
}, []);

During hot reload, editorRef.current.destroy() gets called but there is no another render after, so editor doesn't get reinitialized.

@gaearon
Copy link
Collaborator

gaearon commented Apr 7, 2022

I am getting bugs with react-refresh hot updates, when I'm using lazy ref init with a useEffect() that clears the data.

The problem with your example is that your effect is not symmetric: your cleanup is doing a different thing from your setup. So this breaks Fast Refresh, and will also break in Strict Mode in 18 which double-calls effects to check whether your component is ready for reusable state.

You could do this to make it symmetric:

useEffect(() => {
  let editor = editorRef.current = new Editor();
  return () => {
    editorRef.current = null;
    editor.destroy();
  };
}, []);

It's hard to say more without a runnable example.
Check out reactwg/react-18#18 for some ideas.

@panta82
Copy link

panta82 commented Apr 7, 2022

Thanks @gaearon. I have indeed moved ref initialization into useEffect, and that has solved my problem.

In general, the solution with useEffect isn't 100% ideal because you don't have the ref instance available during the initial render. However, I was able to work around that in my use case.

For curiosity's sake, here's a sandbox with the repro of what I think was happening: https://codesandbox.io/s/musing-kowalevski-3omfcs?file=/src/App.js You can trigger hot refresh by adding a comment or similar.

@gaearon
Copy link
Collaborator

gaearon commented Apr 7, 2022

you don't have the ref instance available during the initial render.

That’s right. But that’s on purpose.

Renders in React are supposed to be discardable. For example, with Suspense, if a newly mounting part of the tree suspends, React 18 will throw it away and add a placeholder instead. That tree never had a chance to mount so we don’t fire its cleanup. So if your instance requires to be destroyed you would have a memory leak.

Delaying its creation to effect avoids that problem. Another way to avoid that problem is to split the external thing apart. The part that doesn’t consume resources (which is free to discard) is created during first render. So it’s safe to throw away. The part that consumes resources (for example, creates a subscription) is created in the effect. So it has a symmetric cleanup.

Hope this helps.

@panta82
Copy link

panta82 commented Apr 7, 2022

@gaearon Understood. Thank you.

@OliverJAsh
Copy link

This seems like a common requirement so is there a reason why we can't update useRef to accept a function as suggested in the original post?

sarahdayan added a commit to algolia-samples/storefront-demo-nextjs that referenced this issue Sep 13, 2022
This uses lazy refs instead of standard refs to lazily instantiate
plugins and ensure they only get called once.

This method is recommended over `useMemo` because it's not designed to
retain values, and could recompute arbitrarily in the future (see
facebook/react#14490 (comment)).
sarahdayan added a commit to algolia-samples/storefront-demo-nextjs that referenced this issue Sep 13, 2022
This uses lazy refs instead of standard refs to lazily instantiate
plugins and ensure they only get called once.

This method is recommended over `useMemo` because it's not designed to
retain values, and could recompute arbitrarily in the future (see
facebook/react#14490 (comment)).
chance-an added a commit to sematic-ai/sematic that referenced this issue Apr 7, 2023
A bug occurs when logs are already loading, and an accidental
simultaneous scroll event triggers another log pull. The second pull
will append the same logs twice.

According to the previous work, we can already sequentialize the pulls
using a queue. So the fix is to avoid the second pull being issued if we
discovered from the first pull that there would be no more logs.

This PR does such, and some extras:

1. Add more loggings to understand the async queuing and execution
details
2. A helper function `useRefFn` to avoid the initial value of `useRef`
being re-created multiple times across rendering. (see also
[here](facebook/react#14490))
3. Convert `AsyncInvocationQueue` from a function to a class to carry
more responsibilities
4. Updated unit test accordingly.

---------

Co-authored-by: tscurtu <tudor@sematic.dev>
@garretwilson
Copy link

garretwilson commented May 5, 2023

Delaying its creation to effect avoids that problem.

@gaearon I'm still unclear what "problem" will be caused if we immediately initialize the reference as @TrySound suggested:

const instance = React.useRef(null)
if (instance.current == null) {
  instance.current = {
    // whatever you need
  }
}

Will that cause any potential issues? Or were you comparing the "delay creation until effect" approach with something else?

The benefit of this approach, if we know we'll need the instance, is that we've consolidated creation in one place, as we may have multiple consumers/users of the instance and it's error-prone to rely on the users to generate the data when they need it, it results in duplicated initialization code, it spreads initialization code around, etc.

@tippfelher
Copy link

@garretwilson because your ref has the wrong type....

@everyone_else: Use this when your ref initializers never return null.

// useLazyRef.ts
import { useRef } from 'react';

function useLazyRef<T>(initializer: () => (null extends T ? never : T)) {
  const ref1 = useRef<T | null>(null);
  if (ref1.current === null) {
    ref1.current = initializer();
  }

  const ref2 = useRef(ref1.current);
  return ref2;
}

export default useLazyRef;

Usage:

import useLazyRef from '@/useLazyRef';

// ...
const numberRef1 = useLazyRef(() => 123);
// good: numberRef1 is of type MutableRefObject<number>

const numberRef2 = useLazyRef<number>(() => 123);
// good: numberRef2 is of type MutableRefObject<number>

const failRef1 = useLazyRef(() => null);
// good: TS error

const failRef2 = useLazyRef<boolean>(() => 123);
// good: TS error

fwcd added a commit to ProjectLighthouseCAU/luna that referenced this issue Aug 21, 2023
jrcribb pushed a commit to jrcribb/sematic that referenced this issue Apr 9, 2024
A bug occurs when logs are already loading, and an accidental
simultaneous scroll event triggers another log pull. The second pull
will append the same logs twice.

According to the previous work, we can already sequentialize the pulls
using a queue. So the fix is to avoid the second pull being issued if we
discovered from the first pull that there would be no more logs.

This PR does such, and some extras:

1. Add more loggings to understand the async queuing and execution
details
2. A helper function `useRefFn` to avoid the initial value of `useRef`
being re-created multiple times across rendering. (see also
[here](facebook/react#14490))
3. Convert `AsyncInvocationQueue` from a function to a class to carry
more responsibilities
4. Updated unit test accordingly.

---------

Co-authored-by: tscurtu <tudor@sematic.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests