1

I'm trying to set a timer on a React/UseEffect hook but also have it execute on the first load. Here's my solution, which doesn't feel correct. I'm running Next.js 13 and in short, using a useState hook variable (initialLoad) to control when the timer is allowed to get set.

Is there a more elegant/better way to do this?

"use client";

import { useEffect, useState } from "react";

function Users() {
  const [users, setUsers] = useState([]);
  const [initialLoad, setInitialLoad] = useState(true);

  const getUsers = async () => {
    const users = await fetch("/api/users").then((response) => response.json());
    return users.auth;
  };

  useEffect(() => {
    console.log(`${new Date()} - 1`);

    (async () => {
      const users = await getUsers();
      setUsers(users);
      setInitialLoad(false);
    })();
  }, []);

  useEffect(() => {
    if (initialLoad) return;

    console.log(`${new Date()} - 2`);

    const usersTimeoutId = setTimeout(async () => {
      const users = await getUsers();
      setUsers(users);
    }, 30000);

    return () => {
      clearTimeout(usersTimeoutId);
    };
  }, [users, initialLoad]);

  return (
    <div>
      <h2>Users</h2>
      {users?.length > 0 &&
        users.map((user: any, ctr: number) => (
          <li key={ctr}>
            {user.name} - {user.email}
          </li>
        ))}
    </div>
  );
}

export default Users;
Gary
  • 909
  • 9
  • 26
  • 1
    Rather than [poll](https://en.wikipedia.org/wiki/Polling_(computer_science)) for updates, it would be more efficient to modify the server API to accept long-lasting connections and [stream](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams) updates to the client. Even though `ReadableStream`s are now standardized, you'll probably find more discussion around `WebSocket` implementations at this point in time. – jsejcksn Jan 16 '23 at 23:34
  • Yes, good suggestion. This was just a test to see if I understood how to do what I coded above. Regarding polling for updates, is there a better or more elegant way? Or did I do it correctly in your opinion? – Gary Jan 16 '23 at 23:37
  • I would have done more or less what you have done here, I don't see any issue with it at all apart from some minor things, such as putting your function is a `useCallback` hook (albeit not really necessary), removing the self invoking part from the first effect (not needed), etc. Perhaps even put it all into one effect as well? I would also be a bit cautious using `ctr` as a key (which really is the array index - you should rename it), since your list could change between each render (read more here https://stackoverflow.com/a/43892905/2030321) – Chris Jan 16 '23 at 23:39
  • [^](https://stackoverflow.com/questions/75140656/how-do-i-set-a-timer-on-react-useeffect-hook-and-also-have-it-execute-on-load#comment132598829_75140656) @Gary Is your goal to poll again **every** 30s _OR_ 30s **after** the previous promise is settled? The code indicates the latter, but it's an important point to clarify. – jsejcksn Jan 17 '23 at 00:04
  • Every 30 seconds. – Gary Jan 17 '23 at 00:39

2 Answers2

0

Here's a cleaner setup:

  1. Move the getUsers outside the Component.
  2. Have everything run in a single useEffect (once, on mount)
  3. Stick with async/await syntax
const getUsers = async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/users");
  const users = await response.json();
  return users.auth;
};

function Users() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    let usersTimeoutId;
    (async () => {
      const users = await getUsers();
      setUsers(users);
      usersTimeoutId = setTimeout(async () => {
        const users = await getUsers();
        setUsers(users);
      }, 1000);
    })();
    return () => {
      clearTimeout(usersTimeoutId);
    };
  }, []);

  return ...
}
Kostas Minaidis
  • 4,681
  • 3
  • 17
  • 25
  • Using `setTimeout` like this without the `users` state value in the dependency list will update the state once and then never again. Did you mean to use `setInterval`/`clearInterval`? – jsejcksn Jan 16 '23 at 23:54
  • @jsejcksn I think the intended behaviour is to run the code just once, hence the use of setTimeout, unless I misinterpreted the author's intentions. – Kostas Minaidis Jan 17 '23 at 00:29
0

Below is a refactor of your code which includes inline comments to explain. Because you are performing asynchronous tasks, you'll need to support cancellation or you'll encounter a common no-op memory leak error, as described in this question: Can't perform a React state update on an unmounted component.

Note: The code in your question includes TypeScript syntax, so I wrote the answer using TypeScript syntax. If you want plain JavaScript for some reason, the linked TypeScript Playground includes the transpiled JSX.

TS Playground

import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";

/** A custom hook for discriminating first render */
function useIsFirstRender (): boolean {
  const ref = useRef(true);
  return ref.current ? !(ref.current = false) : false;
}

// Based on the code you provided:
type User = Record<"email" | "name", string>;
type UserResponse = { auth: User[] };

// This is not a closure, so moving it outside the component
// allows for stable object identity:
async function getUsers (
  { signal = null }: { signal?: AbortSignal | null | undefined } = {},
): Promise<User[]> {
  // Using the AbortSignal API allows for cancellation:
  signal?.throwIfAborted();
  // Alternatively — if your target environment doesn't yet support the above method:
  // if (signal?.aborted) throw signal.reason;
  const response = await fetch("/api/users", { signal });
  const data = await response.json() as UserResponse;
  return data.auth;
};

function Users (): ReactElement {
  const isFirstRender = useIsFirstRender();
  const [users, setUsers] = useState<User[]>([]);

  // Put the state-updating functionality in a reusable closure function.
  // This function needs to be wrapped by the useCallback hook
  // in order to maintain a stable object identity across renders:
  const updateUsers = useCallback(async (
    { signal }: { signal?: AbortSignal } = {},
  ): Promise<void> => {
    try {
      const users = await getUsers({ signal });
      setUsers(users);
    }
    catch (ex) {
      if (signal?.aborted && Object.is(ex, signal.reason)) {
        // Handle aborted case here (probably a no-op in your situation):
        console.error(ex);
      }
      else {
        // Handle other exceptions here:
        console.error(ex);
      }
    }
  }, [setUsers]);

  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;

    // Immediately update users on first render only:
    if (isFirstRender) updateUsers({ signal });

    // Set an interval to update users every 30s
    const timerId = setInterval(() => updateUsers({ signal }), 30e3);

    // Cleanup function:
    return () => {
      // Abort any in-progress fetch requests and updates:
      controller.abort(new Error("Component is re-rendering or unmounting"));
      // Cancel the interval:
      clearInterval(timerId);
    };
  }, [isFirstRender, updateUsers]);

  const userListItems = users.length > 0
    ? users.map(user => {
      // Ref: https://reactjs.org/docs/lists-and-keys.html#keys
      // Keys must be both unique and stable:
      const value = `${user.name} - ${user.email}`;
      return (<li key={value}>{value}</li>);
    })
    : null;

  return (
    <div>
      <h2>Users</h2>
      {/* List item elements should be children of list elements */}
      <ul>{ userListItems }</ul>
    </div>
  );
}

export default Users;

jsejcksn
  • 27,667
  • 4
  • 38
  • 62