1

So basically I have a debounced input component that exposes 2 ref methods, one to clear the input's value and one to set it to a string.

The problem is, using the ref method from the parent component does not work.

Code:

import React, { ChangeEvent, forwardRef, useImperativeHandle, useState } from 'react';
import { TextInput } from 'components/common';
import { useDebounce } from 'utilities/CustomHooks';
import Logger from 'utilities/Logger';

export type DebouncedInputRef = {
  clearValue: () => void;
  setValue: (value: string) => void;
};

export const DebouncedInput = forwardRef(
  (
    { onChange, ...textInputProps }: ComponentProps.DebouncedInputProps,
    ref,
  ) => {
    const [textInputValue, setTextInputValue] = useState('');
    const debouncedOnChange = useDebounce((newValue: string) => {
      onChange && onChange(newValue);
    }, 1000);

    useImperativeHandle(
      ref,
      (): DebouncedInputRef => {
        return {
          clearValue: () => {
            setTextInputValue('');
          },
          setValue: (newValue: string) => {
            Logger.debug('DebouncedInput', 'setValue fired with', newValue);
            setTextInputValue(newValue);
          },
        };
      },
    );

    return (
      <div>
        {Logger.debug('DebouncedInput', 'in render value', textInputValue)}
        <TextInput
          {...textInputProps}
          value={textInputValue}
          onChange={(e: ChangeEvent<HTMLInputElement>) => {
            setTextInputValue(e.target.value);
            debouncedOnChange(e.target.value);
          }}
        />
      </div>
    );
  },
);

The code being used to call ref method is as follows:

const ListProducts = () => {
  const debouncedInputRef = useRef<DebouncedInputRef>();
  
  useEffect(() => {
    debouncedInputRef?.current && debouncedInputRef.current.setValue('Test');
  }, []);

  return (
    <DebouncedInput
      ref={debouncedInputRef}
    />
  );
};

The Logger.debug in setValue prints the incoming value from the parent component.

The Logger.debug in render also runs twice, signifying that re-render occurs right after setTextInputValue is called.

However, the value of the state variable during the render is the same as before.

Basically, setValue runs, but the state variable is not updated, and I have no idea why.

Any pointers will be very welcome.


UPDATE:

Okay, so I got it working. Basically, my ListProducts component had a little extra detail:

const ListProducts = () => {
  const [loading, setLoading] = useState(false);
  const debouncedInputRef = useRef<DebouncedInputRef>();
  
  const mockApiCall = () => {
    setLoading(true);
    // call API and then
    setLoading(false);
  };

  useEffect(() => {
    debouncedInputRef?.current && debouncedInputRef.current.setValue('Test');
    mockApiCall();
  }, []);

  if (loading) {
    return <div>Spinner here</div>;
  }
  return (
    <DebouncedInput
      ref={debouncedInputRef}
    />
  );
};

What I believe the problem was, the ref was capturing the initial DebouncedInput, and then API was called, which returned the spinner and removed Debounced Input from the DOM.

And later when API was done, it was rendered again, but I guess it was a different DOM element?

I'm not sure why this happened, but it was so. I'd be glad to know what exactly was causing the issue.

Here's a code sandbox example with both, working and not working examples.

If anyone could elaborate on what exactly is the issue in the not working example, I'd be very grateful :)

Aayush Gupta
  • 63
  • 4
  • 12
  • Please just add a codesandbox reproducible example – Dennis Vash May 05 '21 at 08:10
  • I got it working. I'll be posting the problem as an answer, but here's the codesandbox for both, working and not working examples: [link](https://codesandbox.io/s/eloquent-williams-skbdh) – Aayush Gupta May 05 '21 at 09:21
  • Although, I'm still not sure what the problem is in the 'Not working example'. Any explanations would be very helpful :) – Aayush Gupta May 05 '21 at 09:29
  • You should just update your post instead of asking a question in self posted answer – Dennis Vash May 05 '21 at 09:39
  • The reason why its not working is due the render timing, in your "working example" you actually remount the components on every render, you missing a render to update your ref. Its related to this question: https://stackoverflow.com/questions/60476155/is-it-safe-to-use-ref-current-as-useeffects-dependency-when-ref-points-to-a-dom/60476525#60476525 – Dennis Vash May 05 '21 at 09:40
  • @DennisVash Thanks for the advice, I posted an update. Regarding the explanation, do all react components remount children on every render? Or are the components I'm writing have something peculiar. Further, I'm not sure what 'missing a render to update your ref means'. I went through the link, but unfortunately, I didn't understand much – Aayush Gupta May 05 '21 at 10:29
  • I'll try adding an answer – Dennis Vash May 05 '21 at 11:32

1 Answers1

1

First lets start with small tweaks:

useImperativeHandle should be used with dep array, in this case its empty:

useImperativeHandle(...,[]);

useEffect should always have a SINGLE responsibility, according to your logic, you just want to set ref value on mount, add another useEffect (doesn't effect anything in this specific example)

// API CALl
useEffect(callback1, []);
// Ref set on mount
useEffect(callback2, []);

Then, from the theoretic side, on every state change, React compares React Sub Node trees to decide if an UI update needed (see Reconciliation).

Those are two different React Nodes:

// #1
<div>
  {renderContent()}
</div>

// #2
<div>
  <DebouncedInput ref={myCompRef} />
  {loading && <span>Loading right now</span>}
</div>

Since in #1, the function called renderContent() on every render, therefore you actually re-mount the node on every render.

Why your code didn't work? Because you called some logic on parent MOUNT:

useEffect(() => {
  myCompRef.current?.setValue("Value set from ref method");
}, [])

If the ref mounted, it worked, if its not, there wasn't function call.

But in context of #1 tree, you instantly unmounted it on next render, therefore you reset the inner value state.

In context of #2, its the same React node, therefore React just updated it.

Edit Q-67397086-SameReactNode

Dennis Vash
  • 50,196
  • 9
  • 100
  • 118
  • 1
    Thank you very much for the detailed explanation and the tweaks. I'll use the tweaks in my code. Also, the Reconciliation doc was a very interesting read, thanks for that too. That and your explanation helped me completely understand the problem, and how to avoid it in future. Every day I learn something new; today, thanks a lot to you :) – Aayush Gupta May 05 '21 at 14:00