Hone logo
Hone
Problems

Implement useAsyncRetry Hook in React

This challenge focuses on creating a custom React hook, useAsyncRetry, designed to handle asynchronous operations that might fail temporarily. The hook will automatically retry the operation a specified number of times if it encounters an error, providing a robust way to deal with flaky network requests or other unpredictable asynchronous tasks.

Problem Description

You need to implement a custom React hook called useAsyncRetry in TypeScript. This hook should accept an asynchronous function (a function that returns a Promise) and an options object. The hook should:

  1. Execute the asynchronous function: When the component using the hook mounts or when a manual retry is triggered.
  2. Manage state: Keep track of the loading status, any data returned by the function, and any errors encountered.
  3. Implement retry logic: If the asynchronous function rejects (throws an error), the hook should automatically re-execute the function up to a specified number of times.
  4. Expose results: Return the current loading state, the data (if successful), and the error (if all retries fail).
  5. Provide a manual retry function: Allow the user to manually trigger a retry of the asynchronous operation.

Key Requirements:

  • The hook should accept an asynchronous function asyncFn as its first argument. This function should return a Promise.
  • The hook should accept an optional options object as its second argument.
    • retries: The maximum number of times to retry the operation if it fails (default: 3).
    • delay: The delay in milliseconds between retries (default: 1000ms). This delay should apply after an error occurs.
    • initialCall: A boolean indicating whether to call the asyncFn immediately on mount (default: true).
  • The hook should return an object with the following properties:
    • data: The resolved value of the asyncFn if successful, or undefined if still loading or if an error occurred and retries were exhausted.
    • error: The error object if the asyncFn fails and all retries are exhausted, or undefined otherwise.
    • loading: A boolean indicating whether the asyncFn is currently executing or retrying.
    • retry: A function that, when called, will manually trigger the asyncFn execution, resetting the retry count.

Expected Behavior:

  • If initialCall is true, asyncFn should be called automatically when the hook mounts.
  • If asyncFn resolves successfully, loading should become false, error should be undefined, and data should hold the resolved value.
  • If asyncFn rejects, and the number of retries has not been exhausted, the hook should wait for delay milliseconds and then re-execute asyncFn. The loading state should remain true during this process.
  • If asyncFn rejects and all retries are exhausted, loading should become false, data should be undefined, and error should hold the last encountered error.
  • Calling the returned retry function should reset the internal retry count and execute asyncFn again. It should also set loading to true.

Edge Cases to Consider:

  • What happens if the asyncFn changes between renders? The hook should ideally re-execute with the new function.
  • What if retries is 0? The function should only be called once.
  • What if delay is 0? Retries should happen immediately.
  • Race conditions: Ensure that only the result of the latest execution attempt is used.

Examples

Example 1: Successful Operation

Let's say we have an API call that succeeds on the first try.

// Mock async function that resolves immediately
const successfulApiCall = async () => {
  await new Promise(resolve => setTimeout(resolve, 100)); // Simulate network latency
  return { message: 'Success!' };
};

// Inside a React component:
const { data, error, loading, retry } = useAsyncRetry(successfulApiCall, { retries: 2, delay: 500 });

// Initial render:
// loading: true
// data: undefined
// error: undefined

// After 100ms:
// loading: false
// data: { message: 'Success!' }
// error: undefined

Explanation: The successfulApiCall resolves quickly. The hook sets loading to true initially, then updates to false with the data once the promise resolves. No retries are needed.

Example 2: Operation with Retries

Consider an API that sometimes fails and requires retries.

// Mock async function that fails twice, then succeeds
let callCount = 0;
const flakyApiCall = async () => {
  callCount++;
  await new Promise(resolve => setTimeout(resolve, 100)); // Simulate network latency
  if (callCount < 3) {
    throw new Error(`Attempt ${callCount} failed`);
  }
  return { message: 'Finally!' };
};

// Inside a React component:
const { data, error, loading, retry } = useAsyncRetry(flakyApiCall, { retries: 2, delay: 500 });

// Initial render:
// loading: true
// data: undefined
// error: undefined

// After 100ms (first attempt fails):
// loading: true (still loading as it will retry)
// error: undefined (retry logic is in place)
// data: undefined

// After 500ms delay + 100ms for second attempt (fails again):
// loading: true
// error: undefined

// After 500ms delay + 100ms for third attempt (succeeds):
// loading: false
// data: { message: 'Finally!' }
// error: undefined

Explanation: The flakyApiCall fails on the first two attempts. The useAsyncRetry hook catches the errors, waits for the specified delay, and retries. On the third attempt, the call succeeds, and the hook updates its state with the data. The total retries allowed was 2, meaning it attempted the call a total of 3 times (initial + 2 retries).

Example 3: Exhausted Retries

An operation that consistently fails.

// Mock async function that always fails
const alwaysFailingApiCall = async () => {
  await new Promise(resolve => setTimeout(resolve, 100));
  throw new Error('Persistent failure');
};

// Inside a React component:
const { data, error, loading, retry } = useAsyncRetry(alwaysFailingApiCall, { retries: 1, delay: 500 });

// Initial render:
// loading: true
// data: undefined
// error: undefined

// After 100ms (first attempt fails):
// loading: true

// After 500ms delay + 100ms for second attempt (fails again):
// loading: false (all retries exhausted)
// data: undefined
// error: Error('Persistent failure')

Explanation: The alwaysFailingApiCall fails on the first attempt. The hook retries once. Since that also fails and all retries are exhausted, the hook updates its state to reflect the final error.

Constraints

  • The asyncFn must be a function that returns a Promise.
  • The retries option should be a non-negative integer.
  • The delay option should be a non-negative integer representing milliseconds.
  • The hook should be implemented using standard React hooks (useState, useEffect, useCallback, useRef).
  • The implementation should prevent unnecessary re-renders and handle potential race conditions where a later, slower call might overwrite the result of an earlier, faster call.

Notes

  • Consider how to manage the asyncFn if it's redefined on every render. Using useRef to store the latest asyncFn can be beneficial.
  • The retry function returned by the hook should also reset the internal retry counter.
  • Think about cleanup: If the component unmounts while a retry is pending, the effect should be cancelled to prevent state updates on unmounted components.
  • The loading state should be true from the moment the function starts executing (either initially or on retry) until it either resolves successfully or all retries are exhausted.
Loading editor...
typescript