2

I have a simple user component that loads a specific user entry in it:

https://playcode.io/977083

Essentally all it does it first loads a list of users, creates buttons for all them and displays one if selected. Once that happens, the users data is loaded and displayed. The control also refreshes itself automatically every 2 seconds.

Only the problem is that if in between the refresh timer being fired and the request from the api being the returned, a new user is selected, the refresh action will display the previous user instead of the new one, before correcting itself and loading the correct user in the subsequent load.

This happens because while the timer is cleared after a new user is selected, the previous timers request may still be running.

I think in order for this to work I need to detect at line 48 that the user has changed since the refresh was started and not setUserData

The problem is I have no way of finding out the actual current value of the user property.

Is there a way to do this or am I just going about it the wrong way?

user2741831
  • 2,120
  • 2
  • 22
  • 43

2 Answers2

2

The problem in line 48 is that you see old values in the closure (=surrounding state) from the time the callback was created.

In order to detect that the user has changed, you could use a mutable ref object:

import {useRef} from 'react';
…
function User({user}) {
  const ref = useRef(user);
  …
  useEffect(() => {
    ref.current = user; //update the ref along with the user
    …
  }, [user]);

Then in line 48, you could detect that the user has changed:

…
.then(json => {
  if (user !== ref.current) {
    return;
  }
  setUserData(json);
  …
Knight Industries
  • 1,325
  • 10
  • 20
  • seams like a very anti-react way to go about this tbh. It introduces mutable state for something so simple as a refreshing data view – user2741831 Oct 07 '22 at 17:43
  • One could argue that the description of the problem as "finding out the actual current value" refers by definition to a state that is mutable. – Knight Industries Oct 07 '22 at 18:21
  • @user2741831 You're not going to be able to solve this without using some additional stateful value for comparison/cancellation. And if you don't want to cause a re-render, then refs are React's idiomatic mechanism for such a purpose. Even if you go looking for some "not-mutable" alternative (like a `usePreviousValue` hook, etc.), you'll see that they all use (mutable) refs internally, so it's just an abstraction. – jsejcksn Oct 07 '22 at 18:44
  • You don't need to add any additional state or refs. – morganney Oct 07 '22 at 19:12
1

Is there a way to do this or am I just going about it the wrong way?

There is a way to do it and I'd say you are going about it in slightly the wrong way.

You can use AbortController to cancel an ongoing fetch.

Check out this example on codesandbox.

I don't think SO code snippets work when using React/JSX but here is an example of a snippet.

const { useState, useEffect } = React;
const User = ({ user }) => {
  const [userData, setUserData] = useState(user);
  const [refreshCount, setRefreshCount] = useState(0);

  useEffect(() => {
    let timeout = null;
    const controller = new AbortController();
    const fetchUser = () => {
      fetch(`https://jsonplaceholder.typicode.com/users/${user.id}`, {
        signal: controller.signal
      })
        .then((response) => response.json())
        .then((json) => {
          setUserData(json);
          setRefreshCount((refreshCount) => refreshCount + 1);
          setTimeout(() => {
            fetchUser();
          }, 2000);
        })
        .catch((err) => {
          if (err.name !== "AbortError") {
            throw err;
          }
        });
    };

    setRefreshCount(0);
    setUserData(user);
    timeout = setTimeout(() => {
      fetchUser();
    }, 2000);

    return () => {
      clearTimeout(timeout);
      controller.abort();
    };
  }, [user]);

  return (
    <div>
      <h5>refreshes: {refreshCount}</h5>
      <h1>{userData.name}</h1>
      <h2>{userData.username}</h2>
      <h3>{userData.email}</h3>
      <hr />
      <h4>{userData.address.street}</h4>
      <h4>{userData.address.suite}</h4>
      <h4>{userData.address.city}</h4>
      <hr />
      <h4>{userData.phone}</h4>
      <h4>{userData.website}</h4>
    </div>
  );
};
const UserButtons = () => {
  const [users, setUsers] = useState([]);
  const [currentUser, setCurrentUser] = useState(null);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((response) => response.json())
      .then((json) => setUsers(json));
  }, []);

  return (
    <div style={{ minHeight: "500pt" }}>
      {users.map((user) => (
        <button
          key={user.id}
          disabled={user.id === currentUser?.id}
          onClick={() => setCurrentUser(user)}
        >
          {user.name}
        </button>
      ))}
      <div>{currentUser && <User user={currentUser} />}</div>
    </div>
  );
};
ReactDOM.render(() => {
  return (
    <div className="App">
      <UserButtons />
    </div>
  )
}, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Other differences of significance:

  • fetchUser is recursive making a call to setTimeout instead of using setInterval.
  • The User component cancels the recursive fetchUser and any ongoing fetch for the previously selected user in the useEffect's cleanup callback and you run this effect whenever the user prop changes.
  • You catch fetch errors and ignore ones related to calling abort by inspecting the name of the error to see if it is AbortError.
  • The currentUser is a complete user object instead of just user.id to simplify some of the logic.
  • I have removed some of the conditional checks for determining whether the refreshCount should be updated. It should be a simple matter of adding those back as React props in any way you see fit.
morganney
  • 6,566
  • 1
  • 24
  • 35
  • Note that your error discrimination works in this specific example, but [the spec is changing](https://github.com/nodejs/node/issues/43874) so the technique won't apply generally in the future. – jsejcksn Oct 07 '22 at 21:07
  • @jsejcksn you are referring to a node issue, the spec doesn't say anything about not being able to distinguish an abort error when using AbortController. – morganney Oct 07 '22 at 21:13
  • [^](https://stackoverflow.com/questions/73947237/cancel-refresh-if-url-has-changed-with-functional-components/73991620?noredirect=1#comment130646797_73991620) I posted the wrong link in the first [comment](https://stackoverflow.com/questions/73947237/cancel-refresh-if-url-has-changed-with-functional-components/73991620?noredirect=1#comment130646704_73991620). Here's the one I intended to include: https://github.com/whatwg/dom/issues/1030. In the future, the `reason` (exception value) will be forwarded if provided instead of the value always being an `AbortError`. – jsejcksn Oct 07 '22 at 21:16
  • When user agents start implementing any spec changes, you refactor your code. Such is software. Let me know when it changes and you can suggest an edit. – morganney Oct 07 '22 at 21:19
  • [^](https://stackoverflow.com/questions/73947237/cancel-refresh-if-url-has-changed-with-functional-components/73991620?noredirect=1#comment130646861_73991620) You can see the approach in [this answer](https://stackoverflow.com/a/73050230/438273) for a future-proof generic alternative (compare the provided abort reason with the exception thrown) – jsejcksn Oct 07 '22 at 21:21
  • You mean pass a `string` to `abort()`? Ok. – morganney Oct 07 '22 at 21:23
  • [^](https://stackoverflow.com/questions/73947237/cancel-refresh-if-url-has-changed-with-functional-components/73991620?noredirect=1#comment130646925_73991620) You can, but [I don't recommend it](https://stackoverflow.com/q/11502052/438273). – jsejcksn Oct 07 '22 at 21:28