Home > Back-end >  How do I set a timer on React/UseEffect hook and also have it execute on load?
How do I set a timer on React/UseEffect hook and also have it execute on load?

Time:01-17

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;

CodePudding user response:

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 ...
}

CodePudding user response:

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;

  • Related