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:
- Move the
getUsers
outside the Component. - Have everything run in a single useEffect (once, on mount)
- 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.
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;