Hone logo
Hone
Problems

Building useOptimistic for Instant UI Updates in React

This challenge asks you to implement a custom React hook called useOptimistic. This hook is designed to improve user experience by providing immediate feedback for actions that involve asynchronous updates, such as submitting a form or adding an item to a list. The goal is to make your UI feel more responsive by rendering optimistic updates before the actual server response is received.

Problem Description

You need to create a custom React hook useOptimistic<TData, TArgs>(options: UseOptimisticOptions<TData, TArgs>) that allows developers to implement optimistic UI updates. The hook should manage two states: the current data and the pending optimistic data.

Key Requirements:

  1. State Management:

    • The hook should accept an initialData argument representing the current, stable state of the data.
    • It should maintain an internal state for the optimisticData. Initially, optimisticData will be the same as initialData.
    • When an optimistic update is triggered, the optimisticData should be updated immediately.
  2. Update Function:

    • The hook should accept an updateFn argument. This function takes the current optimisticData and the arguments used for the optimistic update, and returns the new optimisticData.
    • The hook should also accept an actualFn argument. This function takes the arguments used for the optimistic update and performs the actual asynchronous operation (e.g., an API call). It should return the final, confirmed data from the server.
  3. Triggering Updates:

    • The hook should return a function, let's call it mutate, which will be used to trigger the optimistic update.
    • When mutate is called with arguments (let's call them args), the following should happen:
      • The optimisticData should be updated immediately using updateFn(optimisticData, args).
      • The actualFn(args) should be called asynchronously.
      • Upon successful completion of actualFn, the initialData (which represents the stable source of truth) should be updated with the result from actualFn. This update should also be reflected in the optimisticData.
      • If actualFn throws an error, the optimisticData should be reset to the initialData (or a previous stable state, handled internally by the hook).
  4. Return Values:

    • The hook should return an object containing:
      • data: The current stable data (derived from initialData).
      • optimisticData: The data currently being displayed, which might be the optimisticData or the data if no optimistic update is pending.
      • mutate: The function to trigger optimistic updates.

Expected Behavior:

  • Initially, data and optimisticData will be the same, reflecting the initialData.
  • When mutate is called, optimisticData updates instantly, and the UI reflects this change.
  • The asynchronous operation runs in the background.
  • If the asynchronous operation succeeds, data is updated, and optimisticData will then also reflect this new stable data.
  • If the asynchronous operation fails, optimisticData reverts to the state before the mutate call, and data remains unchanged.

Edge Cases:

  • Multiple mutate calls before resolution: How does the hook handle concurrent optimistic updates? For this challenge, assume only one mutate call can be active at a time, or that subsequent calls reset the pending operation.
  • Errors during actualFn: The hook must gracefully handle errors and revert the optimistic state.
  • initialData changes: While this hook primarily focuses on optimistic updates triggered by mutate, consider how changes to initialData from outside the hook should affect the internal state. For simplicity, we'll assume initialData is stable or managed externally and doesn't change unexpectedly mid-operation.

Examples

Example 1: Adding an item to a list

Let's say we have a list of todos.

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

// Assume this is the initial state from a server
const initialTodos: Todo[] = [
  { id: 1, text: "Buy groceries", completed: false },
  { id: 2, text: "Walk the dog", completed: true },
];

// Mock API call to add a todo
const addTodoApi = async (text: string): Promise<Todo> => {
  console.log(`API: Adding todo "${text}"`);
  await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate network latency
  const newTodo: Todo = {
    id: Math.random() * 1000, // Generate a temporary ID or get from server
    text,
    completed: false,
  };
  console.log(`API: Successfully added todo "${text}" with ID ${newTodo.id}`);
  return newTodo;
};

// In a component:
// const [todos, setTodos] = useState(initialTodos); // This would be our initialData
// const { optimisticData, mutate } = useOptimistic<Todo[], { text: string }>(
//   initialTodos,
//   (currentTodos, newTodoText) => {
//     // Optimistically add the new todo
//     const optimisticTodo: Todo = {
//       id: Date.now(), // Use a temporary ID for optimistic display
//       text: newTodoText.text,
//       completed: false,
//     };
//     return [...currentTodos, optimisticTodo];
//   },
//   async (newTodoText) => {
//     // Perform the actual API call
//     const createdTodo = await addTodoApi(newTodoText.text);
//     // For this example, we'll assume the API returns a complete Todo object
//     // In a real app, you might need to handle how the ID is updated or merged
//     return createdTodo;
//   }
// );

// When a user types "Learn React Hooks" and submits a form:
// mutate({ text: "Learn React Hooks" });

// Expected behavior:
// 1. Immediately, the UI shows the new todo "Learn React Hooks" with a temporary ID.
// 2. After 1 second, the API call finishes.
// 3. The `data` (and thus `optimisticData`) is updated to include the todo returned by the API.
//    If the API returned a different ID or slightly different data, it would update.

Example 2: Toggling a completion status

// Assume initialTodos is the same as Example 1

// Mock API call to toggle completion
const toggleTodoCompletionApi = async (id: number, completed: boolean): Promise<Todo> => {
  console.log(`API: Toggling todo ${id} to completed: ${completed}`);
  await new Promise(resolve => setTimeout(resolve, 800)); // Simulate network latency
  // In a real scenario, this would fetch the updated todo from the server
  // For this mock, we'll assume it returns the updated state of the todo
  return { id, text: `Todo ${id}`, completed: !completed }; // Simulate server returning opposite state
};

// In a component:
// const [todos, set dois] = useState(initialTodos);
// const { optimisticData, mutate } = useOptimistic<Todo[], { id: number; completed: boolean }>(
//   initialTodos,
//   (currentTodos, updatePayload) => {
//     // Optimistically toggle the completion status
//     return currentTodos.map(todo =>
//       todo.id === updatePayload.id
//         ? { ...todo, completed: !todo.completed }
//         : todo
//     );
//   },
//   async (updatePayload) => {
//     // Perform the actual API call
//     const updatedTodo = await toggleTodoCompletionApi(updatePayload.id, updatePayload.completed);
//     return updatedTodo;
//   }
// );

// When a user clicks to toggle todo with id 1:
// mutate({ id: 1, completed: initialTodos.find(t => t.id === 1)!.completed });

// Expected behavior:
// 1. The UI immediately shows the todo with id 1 as completed/incomplete.
// 2. After 0.8 seconds, the API call finishes.
// 3. The `data` (and thus `optimisticData`) is updated with the actual state of todo 1 returned by the API.
//    If the API returned an error, the UI would revert to the state before the toggle.

Example 3: Handling an error

Consider Example 1, but addTodoApi fails.

// Mock API call that fails
const addTodoApiWithError = async (text: string): Promise<Todo> => {
  console.log(`API: Attempting to add todo "${text}" (will fail)`);
  await new Promise(resolve => setTimeout(resolve, 1000));
  throw new Error("Failed to add todo to the server.");
};

// In a component, using addTodoApiWithError instead:
// const { optimisticData, mutate } = useOptimistic<Todo[], { text: string }>(
//   initialTodos,
//   (currentTodos, newTodoText) => { /* ... same updateFn ... */ },
//   async (newTodoText) => {
//     // This will throw an error
//     const createdTodo = await addTodoApiWithError(newTodoText.text);
//     return createdTodo;
//   }
// );

// When `mutate({ text: "Fail this todo" })` is called:
// Expected behavior:
// 1. The UI immediately shows the new todo "Fail this todo".
// 2. After 1 second, the `addTodoApiWithError` throws an error.
// 3. The hook catches the error.
// 4. The `optimisticData` is reverted to the state before the `mutate` call.
// 5. The UI reverts to showing the original list of todos.

Constraints

  • The initialData can be any type TData.
  • The arguments passed to mutate will be of type TArgs.
  • updateFn must be a pure function: (currentData: TData, args: TArgs) => TData.
  • actualFn must be an asynchronous function: (args: TArgs) => Promise<TData>.
  • The hook should be efficient and not cause unnecessary re-renders.

Notes

  • This hook is a simplification of libraries like TanStack Query or Zustand's optimistic updates. Focus on the core mechanism of managing optimistic state and reverting on error.
  • You will need to use React's useState and useReducer hooks internally.
  • Consider how to handle the data state, which represents the confirmed server state. It should only be updated when actualFn succeeds.
  • The optimisticData is what the user sees. It should be updated immediately upon calling mutate.
  • Think about how to manage the "pending" state if needed, though for this challenge, directly switching between data and optimisticData is sufficient.
  • The primary goal is to provide a seamless user experience by giving instant feedback.
Loading editor...
typescript