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:
-
State Management:
- The hook should accept an
initialDataargument representing the current, stable state of the data. - It should maintain an internal state for the
optimisticData. Initially,optimisticDatawill be the same asinitialData. - When an optimistic update is triggered, the
optimisticDatashould be updated immediately.
- The hook should accept an
-
Update Function:
- The hook should accept an
updateFnargument. This function takes the currentoptimisticDataand the arguments used for the optimistic update, and returns the newoptimisticData. - The hook should also accept an
actualFnargument. 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.
- The hook should accept an
-
Triggering Updates:
- The hook should return a function, let's call it
mutate, which will be used to trigger the optimistic update. - When
mutateis called with arguments (let's call themargs), the following should happen:- The
optimisticDatashould be updated immediately usingupdateFn(optimisticData, args). - The
actualFn(args)should be called asynchronously. - Upon successful completion of
actualFn, theinitialData(which represents the stable source of truth) should be updated with the result fromactualFn. This update should also be reflected in theoptimisticData. - If
actualFnthrows an error, theoptimisticDatashould be reset to theinitialData(or a previous stable state, handled internally by the hook).
- The
- The hook should return a function, let's call it
-
Return Values:
- The hook should return an object containing:
data: The current stable data (derived frominitialData).optimisticData: The data currently being displayed, which might be theoptimisticDataor thedataif no optimistic update is pending.mutate: The function to trigger optimistic updates.
- The hook should return an object containing:
Expected Behavior:
- Initially,
dataandoptimisticDatawill be the same, reflecting theinitialData. - When
mutateis called,optimisticDataupdates instantly, and the UI reflects this change. - The asynchronous operation runs in the background.
- If the asynchronous operation succeeds,
datais updated, andoptimisticDatawill then also reflect this new stable data. - If the asynchronous operation fails,
optimisticDatareverts to the state before themutatecall, anddataremains unchanged.
Edge Cases:
- Multiple
mutatecalls before resolution: How does the hook handle concurrent optimistic updates? For this challenge, assume only onemutatecall 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. initialDatachanges: While this hook primarily focuses on optimistic updates triggered bymutate, consider how changes toinitialDatafrom outside the hook should affect the internal state. For simplicity, we'll assumeinitialDatais 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
initialDatacan be any typeTData. - The arguments passed to
mutatewill be of typeTArgs. updateFnmust be a pure function:(currentData: TData, args: TArgs) => TData.actualFnmust 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
useStateanduseReducerhooks internally. - Consider how to handle the
datastate, which represents the confirmed server state. It should only be updated whenactualFnsucceeds. - The
optimisticDatais what the user sees. It should be updated immediately upon callingmutate. - Think about how to manage the "pending" state if needed, though for this challenge, directly switching between
dataandoptimisticDatais sufficient. - The primary goal is to provide a seamless user experience by giving instant feedback.