94

tldr; How do I simulate componentDidUpdate or otherwise use the key prop with an array to force my component to be reset?

I'm implementing a component which displays a timer and executes a callback when it reaches zero. The intent is for the callback to update a list of objects. The latter component is made of the new React hooks useState and useEffect.

The state contains a reference to the time at which the timer was started, and the time remaining. The effect sets an interval called every second to update the time remaining, and to check whether the callback should be called.

The component is not meant to reschedule a timer, or keep the interval going when it reaches zero, it's supposed to execute the callback and idle. In order for the timer to refresh, I was hoping to pass an array to key which would cause the component's state to be reset, and thus the timer would restart. Unfortunately key must be used with a string, and therefore whether or not my array's reference has changed produces no effect.

I also tried to push changes to the props by passing the array that I was concerned about, but the state was maintained and thus the interval was not reset.

What would be the preferred method to observe shallow changes in an array in order to force a state to be updated solely using the new hooks API?

import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';

function getTimeRemaining(startedAt, delay) {
    const now = new Date();
    const end = new Date(startedAt.getTime() + delay);
    return Math.max(0, end.getTime() - now.getTime());
}

function RefresherTimer(props) {
    const [startedAt, setStartedAt] = useState(new Date());
    const [timeRemaining, setTimeRemaining] = useState(getTimeRemaining(startedAt, props.delay));

    useEffect(() => {

        if (timeRemaining <= 0) {
            // The component is set to idle, we do not set the interval.
            return;
        }

        // Set the interval to refresh the component every second.
        const i = setInterval(() => {
            const nowRemaining = getTimeRemaining(startedAt, props.delay);
            setTimeRemaining(nowRemaining);

            if (nowRemaining <= 0) {
                props.callback();
                clearInterval(i);
            }
        }, 1000);

        return () => {
            clearInterval(i);
        };
    });

    let message = `Refreshing in ${Math.ceil(timeRemaining / 1000)}s.`;
    if (timeRemaining <= 0) {
        message = 'Refreshing now...';
    }

    return <div>{message}</div>;
}

RefresherTimer.propTypes = {
    callback: PropTypes.func.isRequired,
    delay: PropTypes.number
};

RefresherTimer.defaultProps = {
    delay: 2000
};

export default RefresherTimer;

Attempted to use with key:

<RefresherTimer delay={20000} callback={props.updateListOfObjects} key={listOfObjects} />

Attempted to use with a props change:

<RefresherTimer delay={20000} callback={props.updateListOfObjects} somethingThatChanges={listOfObjects} />

listOfObjects refers to an array of objects, where the objects themselves won't necessarily change, so the array should be compared with !==. Typically, the value will be coming from Redux, where the action updateListOfObjects causes the array to be reinitialised like so: newListOfObjects = [...listOfObjects].

FMCorz
  • 2,586
  • 1
  • 21
  • 18

7 Answers7

150

The useRef creates an "instance variable" in functional component. It acts as a flag to indicate whether it is in mount or update phase without updating state.

const mounted = useRef();
useEffect(() => {
  if (!mounted.current) {
    // do componentDidMount logic
    mounted.current = true;
  } else {
    // do componentDidUpdate logic
  }
});
antihero989
  • 486
  • 1
  • 10
  • 32
Morgan Cheng
  • 73,950
  • 66
  • 171
  • 230
  • 32
    Smart idea! Sad React doesn't cover such basic scenario out-of-the-box. – vsync Aug 26 '19 at 13:59
  • While this does not solve my lengthy question asking how to invalidate a component based on props (or complex key), it does answer the question in the title, so this is the accepted answer! Thanks! – FMCorz Nov 19 '19 at 02:14
  • Hi did react-hooks change? This doesn't work for me: https://stackoverflow.com/questions/65040152/cant-use-usereft-as-componentdidupdate-replacement – Leon Gaban Nov 27 '20 at 15:51
  • What a neat solution, thank you. – A7bert Dec 08 '21 at 21:15
  • 1
    Care to note that when attempting this on react `16.8.3` the if..else mount.current check won't work. It will only execute the componentDidMount logic. The above works only if you're on latest react `18.0.0` and upwards. Just a heads-up for people who has not been in touch with react for the past few years.. – awongCM Dec 29 '22 at 09:54
11

In short, you want to reset your timer when the reference of the array changes, right ? If so, you will need to use some diffing mechanism, a pure hooks based solution would take advantage of the second parameter of useEffect, like so:

function RefresherTimer(props) {
  const [startedAt, setStartedAt] = useState(new Date());
  const [timeRemaining, setTimeRemaining] = useState(getTimeRemaining(startedAt, props.delay));

  //reset part, lets just set startedAt to now
  useEffect(() => setStartedAt(new Date()),
    //important part
    [props.listOfObjects] // <= means: run this effect only if any variable
    // in that array is different from the last run
  )

  useEffect(() => {
    // everything with intervals, and the render
  })
}

More information about this behaviour here https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects

rotimi-best
  • 1,852
  • 18
  • 29
Bear-Foot
  • 744
  • 4
  • 12
  • 1
    nice catch with the second argument of useEffect() which watches for properties changes and fires only then – Evgeni Atanasov Mar 11 '19 at 15:08
  • I agree this is more inline with my question. However, the title of my question being what it is, I have to admit that Morgan is the correct answer. – FMCorz Nov 19 '19 at 02:12
8

use a custom hook

export const useComponentDidUpdate = (effect, dependencies) => {
  const hasMounted = useRef(false);

  useEffect(
    () => {
      if (!hasMounted.current) {
        hasMounted.current = true;
        return;
      }
      effect();
    }, 
    dependencies
  );
};

Effect will not run after the initial render. Thereafter, it depends on the array of values that should be observed. If it's empty, it will run after every render. Otherwise, it will run when one of it's values has changed.

Ben Carp
  • 24,214
  • 9
  • 60
  • 72
  • Do you mind adding an example for how to use this? Is the signature identical to useEffect itself? – Devin Rhode Apr 08 '20 at 00:54
  • Looks like it does work just like useEffect. Thank you for writing this! – Devin Rhode Apr 08 '20 at 01:38
  • @DevinGRhode, the signature is identical. The only difference is that it doesn't run after initial render. Thx – Ben Carp Apr 08 '20 at 08:26
  • Example would be of help. I tried to implement it like this: ```useComponentDidUpdate(() => { forecast && scroll(htmlID.forecast); }, [anything]);``` and it didn't work as expected, no matter what anything was. Nor it did with empty table. It seems to work only with null as dependency (then is equivalent to most up voted answer above) – Kiszuriwalilibori Nov 30 '21 at 10:25
  • @Kiszuriwalilibori care to share a sandobx? – Ben Carp Dec 04 '21 at 07:06
2

Create hook first

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}

Now in the main function

import React, {useEffect, useState} from 'react';
import {Text, View} from 'react-native';
import usePrevious from './usePrevious';

export default function Example() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);
  

  useEffect(() => {
    // this one is your didupdate method for count variable
    if (count != prevCount) {
      alert('count updated')
    }
  }, [count]);



  return (
    <View>
      <Text>
        You clicked {count} times {prevCount}{' '}
      </Text>
      
      <Text onPress={() => setCount(count + 1)}>Increment</Text>

      <Text onPress={() => setCount(count - 1)}>Decrement</Text>
    </View>
  );
}
Rajesh N
  • 6,198
  • 2
  • 47
  • 58
1

You can use useUpdateEffect from react-use.

puchu
  • 3,294
  • 6
  • 38
  • 62
-1

A way to remount a component is to provide new key property. It's not necessarily a string but it will be coerced to a string internally, so if listOfObjects is a string, it's expected that key is compared internally with listOfObjects.toString().

Any random key can be used, e.g. uuid or Math.random(). Shallow comparison of listOfObjects can be performed in parent component to provide new key. useMemo hook can be used in parent state to conditionally update remount key, and listOfObjects can be used as a list of parameters that need to be memoized. Here's an example:

  const remountKey = useMemo(() => Math.random(), listOfObjects);

  return (
    <div>
      <RefresherTimer delay={3000} callback={() => console.log('refreshed')} key={remountKey} />
    </div>
  );

As an alternative to remount key, child component could be able to reset own state and expose a callback to trigger a reset.

Doing shallow comparison of listOfObjects inside child component would be an antipattern because this requires it to be aware of parent component implementation.

Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • Creating a parent component to do the comparison is only pushing my problem outside of my component, how do I achieve the comparison using hooks? Note that just using a random key means that any re-render of the parent component will cause the timer to be lost, this is not an option. Also `listOfObjects` is an array of objects, so coercing it to string won't work: `[object Object],[object Object],[object Object]"`. Lastly, as you said it yourself, the 3rd option is an antipattern. – FMCorz Nov 12 '18 at 09:25
  • Yes, coercing `listOfObjects` won't work, I mentioned this to explain why current attempt doesn't work. Parent component is the component where you use ``, it already exists. *using a random key means that any re-render of the parent component will cause the timer to be lost* - it doesn't mean that. It basically should be `key={this.state.remountKey}` where `remountKey` is something like `shallowEqual(preState.listOfObjects, newState.listOfObjects) && prevState.remountKey || Math.random()`. If you use Redux, remountKey should likely be calculated there. – Estus Flask Nov 12 '18 at 09:36
  • If you want to use hooks, useMemo hook can be likely be used in *parent* component to calculate remountKey. I provided an example that shows the idea. Using an equivalent to componentDidUpdate in *child* component would be a mistake because this requires it to be aware of `listOfObjects` while it shouldn't. – Estus Flask Nov 12 '18 at 10:04
-1

Universal TypeScript version:

import { DependencyList, useEffect, useRef } from "react"

type Destructor = () => void
type MountEffectCallback = (firstLoad: boolean) => (void | Destructor)

export const useDidUpdateEffect = (effect: MountEffectCallback, deps: DependencyList) => {
    const firstLoad = useRef(true)

    useEffect(() => {
        effect(firstLoad.current)

        if (firstLoad.current) {
            firstLoad.current = false
        }
    }, deps)
}
Liam Kernighan
  • 2,335
  • 1
  • 21
  • 24