Избегайте старых данных при использовании useEffect для извлечения данных - PullRequest
2 голосов
/ 12 мая 2019

Моя проблема в том, что когда пользовательский хук использует useEffect с useState (например, для извлечения данных), пользовательский хук возвращает устаревшие данные (из состояния) после изменения зависимостей, но до запуска useEffect.

Можете ли вы предложить правильный / идиоматический способ решения этой проблемы?


Я использую документацию React и эти статьи для руководства:

Я определил функцию, которая использует useEffect и предназначена для переноса выборки данных - исходный код - TypeScript, а не JavaScript, но это не имеет значения - я думаю, что это «по книге»:

function useGet<TData>(getData: () => Promise<TData>): TData | undefined {

  const [data, setData] = React.useState<TData | undefined>(undefined);

  React.useEffect(() => {
    getData()
      .then((fetched) => setData(fetched));
  }, [getData]);

  // (TODO later -- handle abort of data fetching)

  return data;
}

Приложение направляет к различным компонентам в зависимости от URL-адреса - например, вот компонент, который выбирает и отображает данные профиля пользователя (когда ему присваивается URL-адрес, например https://stackoverflow.com/users/49942/chrisw, где 49942 - это «userId»):

export const User: React.FunctionComponent<RouteComponentProps> =
  (props: RouteComponentProps) => {

  // parse the URL to get the userId of the User profile to be displayed
  const userId = splitPathUser(props.location.pathname);

  // to fetch the data, call the IO.getUser function, passing userId as a parameter
  const getUser = React.useCallback(() => IO.getUser(userId), [userId]);

  // invoke useEffect, passing getUser to fetch the data
  const data: I.User | undefined = useGet(getUser);

  // use the data to render
  if (!data) {
    // TODO render a place-holder because the data hasn't been fetched yet
  } else {
    // TODO render using the data
  }
}

Я думаю, что это стандартно - если компонент вызывается с другим userId, useCallback вернет другое значение, и, следовательно, useEffect сработает снова, потому что getData объявлено в его массиве зависимостей.

Однако я вижу следующее:

  1. useGet вызывается впервые - он возвращает undefined, потому что useEffect еще не сработал и данные еще не получены
  2. useEffect срабатывает, данные извлекаются, и компонент повторно визуализируется с извлеченными данными
  3. Если userId изменяется, то useGet вызывается снова - useEffect сработает (потому что getData изменилось), но еще не сработало, поэтому на данный момент useGet возвращает устаревшие данные ( т.е. ни новые данные, ни undefined) - поэтому компонент повторно выполняет рендеринг с устаревшими данными
  4. Вскоре useEffect срабатывает, и компонент перерисовывается с новыми данными

Использование устаревших данных на шаге 3 нежелательно.

Как мне этого избежать? Есть ли нормальный / идиоматический способ?

Я не вижу исправления в статьях, на которые я ссылался выше.

Возможное исправление (т. Е. Это похоже на работу) - переписать функцию useGet следующим образом:

function useGet2<TData, TParam>(getData: () => Promise<TData>, param: TParam): TData | undefined {

  const [prev, setPrev] = React.useState<TParam | undefined>(undefined);
  const [data, setData] = React.useState<TData | undefined>(undefined);

  React.useEffect(() => {
    getData()
      .then((fetched) => setData(fetched));
  }, [getData, param]);

  if (prev !== param) {
    // userId parameter changed -- avoid returning stale data
    setPrev(param);
    setData(undefined);
    return undefined;
  }

  return data;
}

... который, очевидно, компонент вызывает так:

  // invoke useEffect, passing getUser to fetch the data
  const data: I.User | undefined = useGet2(getUser, userId);

... но меня беспокоит, что я не вижу этого в опубликованных статьях - это необходимо и лучший способ сделать это?

Кроме того, если я собираюсь явным образом вернуть undefined, есть ли удобный способ проверить, будет ли срабатывать useEffect, то есть проверить, изменился ли его массив зависимостей? Должен ли я дублировать то, что делает useEffect, явно сохраняя старую функцию userId и / или getData в качестве переменной состояния (как показано выше в функции useGet2)?


Чтобы прояснить, что происходит, и показать, почему добавление «ловушки очистки» неэффективно, я добавил ловушку очистки к сообщениям useEffect плюс console.log, поэтому исходный код выглядит следующим образом.

function useGet<TData>(getData: () => Promise<TData>): TData | undefined {

  const [data, setData] = React.useState<TData | undefined>(undefined);

  console.log(`useGet starting`);

  React.useEffect(() => {
    console.log(`useEffect starting`);
    let ignore = false;
    setData(undefined);
    getData()
      .then((fetched) => {
        if (!ignore)
          setData(fetched)
      });
    return () => {
      console.log("useEffect cleanup running");
      ignore = true;
    }
  }, [getData, param]);

  console.log(`useGet returning`);
  return data;
}

export const User: React.FunctionComponent<RouteComponentProps> =
  (props: RouteComponentProps) => {

  // parse the URL to get the userId of the User profile to be displayed
  const userId = splitPathUser(props.location.pathname);

  // to fetch the data, call the IO.getUser function, passing userId as a parameter
  const getUser = React.useCallback(() => IO.getUser(userId), [userId]);

  console.log(`User starting with userId=${userId}`);

  // invoke useEffect, passing getUser to fetch the data
  const data: I.User | undefined = useGet(getUser);

  console.log(`User rendering data ${!data ? "'undefined'" : `for userId=${data.summary.idName.id}`}`);
  if (data && (data.summary.idName.id !== userId)) {
    console.log(`userId mismatch -- userId specifies ${userId} whereas data is for ${data.summary.idName.id}`);
    data = undefined;
  }

  // use the data to render
  if (!data) {
    // TODO render a place-holder because the data hasn't been fetched yet
  } else {
    // TODO render using the data
  }
}

И вот сообщения журнала времени выполнения, связанные с каждым из четырех шагов, которые я обрисовал выше:

  1. useGet вызывается впервые - он возвращает undefined, поскольку useEffect еще не сработал и данные еще не получены

    User starting with userId=5
    useGet starting
    useGet returning
    User rendering data 'undefined'
    
  2. useEffect запускается, данные извлекаются, и компонент повторно визуализируется с извлеченными данными

    useEffect starting
    mockServer getting /users/5/unknown
    User starting with userId=5
    useGet starting
    useGet returning
    User rendering data for userId=5
    
  3. Если userId изменяется, то useGet вызывается снова - useEffect сработает (потому что getData изменилось), но еще не сработало, поэтому пока useGet возвращает устаревшие данные (т. е. ни новые данные, ни undefined), поэтому компонент повторно выполняет рендеринг с устаревшими данными

    User starting with userId=1
    useGet starting
    useGet returning
    User rendering data for userId=5
    userId mismatch -- userId specifies 1 whereas data is for 5
    
  4. Вскоре useEffect срабатывает, и компонент перерисовывается с новыми данными

    useEffect cleanup running
    useEffect starting
    UserProfile starting with userId=1
    useGet starting
    useGet returning
    User rendering data 'undefined'
    mockServer getting /users/1/unknown
    User starting with userId=1
    useGet starting
    useGet returning
    User rendering data for userId=1
    

Таким образом, очистка выполняется как часть шага 4 (возможно, когда запланировано 2-е useEffect), но еще слишком поздно, чтобы предотвратить возврат устаревших данных в конце шага 3, после userId изменения и до запланированного второго useEffect.

Ответы [ 2 ]

0 голосов
/ 13 мая 2019

В ответе в Твиттере @dan_abramov написал, что мое useGet2 решение более или менее канонично:

Если вы установите setState внутри render [andза пределами useEffect], чтобы избавиться от устаревшего состояния, он никогда не должен производить наблюдаемый пользователем промежуточный рендер.Это запланирует другую перерисовку синхронно.Таким образом, вашего решения должно быть достаточно.

Это идиоматическое решение для производного состояния, и в вашем примере состояние получено из ID.

Как мне реализовать getDerivedStateFromProps?

(В более долгосрочной перспективе будет другое решение для извлечения данных, которое не включает эффекты или состояние установки. Но я описываю то, что мы имеем сегодня.)

статья, на которую ссылается эта ссылка - Возможно, вам не нужно производное состояние - объясняет причину проблемы.

В ней говорится, что проблема заключается в ожидании "контролируемого«состояние, переданное User (то есть userId), чтобы соответствовать« неконтролируемому »внутреннему состоянию (то есть данным, возвращаемым эффектом).

Лучше всего зависеть от одного илидругие, но не смешивать их.

Так что я полагаю, что я должен вернуть userId внутри и / или с данными.

0 голосов
/ 12 мая 2019

Вы должны инициализировать данные каждый раз, когда useEffect вызывается внутри useGet:

function useGet<TData>(getData: () => Promise<TData>): TData | undefined {

  const [data, setData] = React.useState<TData | undefined>(undefined);

  React.useEffect(() => {

    setData(undefinded) // initializing data to undefined

    getData()
      .then((fetched) => setData(fetched));
  }, [getData]);

  // (TODO later -- handle abort of data fetching)

  return data;
}
...