Hone logo
Hone
Problems

Implement a useImmerReducer Hook in React

Many React applications rely on state management, and for complex state, useReducer is a powerful tool. However, updating nested or complex state within a reducer can be verbose and prone to errors. The immer library simplifies this by allowing you to write immutable updates in a mutable style. This challenge asks you to create a custom React hook, useImmerReducer, that combines the functionality of useReducer with the convenience of immer.

Problem Description

Your task is to implement a custom React hook named useImmerReducer that behaves similarly to React's built-in useReducer hook, but integrates immer for simplified state updates. This hook should accept an immer-compatible reducer function and an initial state, and return the current state and a dispatch function. The dispatch function should accept an update operation that immer can process.

Key Requirements:

  1. Hook Signature: The hook should have the signature:
    function useImmerReducer<S, A>(
        reducer: (draft: Draft<S>, action: A) => void | S,
        initialState: S,
        initializer?: (initialState: S) => S
    ): [S, (action: A) => void];
    
    • The reducer function should accept a mutable draft of the state (provided by immer) and the action. It can either directly modify the draft or return a new state.
    • The initializer function is optional and works like in useReducer.
  2. Immer Integration: Internally, the hook must use immer's produce function to create new state based on the reducer's modifications.
  3. State Management: The hook should correctly manage and return the current state.
  4. Dispatch Function: The returned dispatch function should wrap the immer logic and dispatch the action to the internal useReducer instance.
  5. Type Safety: The hook should be fully typed using TypeScript, ensuring type safety for the state and actions.

Expected Behavior:

When the dispatch function is called with an action, the useImmerReducer hook should:

  1. Take the current state.
  2. Pass the current state and the action to immer's produce.
  3. The produce function will then call your provided reducer function with a mutable draft of the state and the action.
  4. Your reducer function can then mutate the draft as if it were regular mutable state.
  5. immer will automatically generate the next immutable state based on the mutations to the draft.
  6. The hook will update its internal state with this new immutable state.

Edge Cases:

  • Initial state with initializer: Ensure the optional initializer function is correctly handled.
  • Reducer returning a value: The reducer can either mutate the draft or return a new state value. Your hook should accommodate both.

Examples

Example 1: Simple Counter

import React, { Dispatch, Reducer, ReducerAction } from 'react';
import { produce, Draft } from 'immer';

// Assume useImmerReducer is implemented elsewhere and imported
// import { useImmerReducer } from './useImmerReducer';

// Define the hook signature for context
declare function useImmerReducer<S, A>(
    reducer: (draft: Draft<S>, action: A) => void | S,
    initialState: S,
    initializer?: (initialState: S) => S
): [S, Dispatch<A>];


interface CounterState {
    count: number;
}

type CounterAction =
    | { type: 'increment' }
    | { type: 'decrement' }
    | { type: 'set', payload: number };

const counterReducer: Reducer<CounterState, CounterAction> = (draft, action) => {
    switch (action.type) {
        case 'increment':
            draft.count++;
            break;
        case 'decrement':
            draft.count--;
            break;
        case 'set':
            draft.count = action.payload;
            break;
    }
};

function CounterComponent() {
    const [state, dispatch] = useImmerReducer(counterReducer, { count: 0 });

    return (
        <div>
            <p>Count: {state.count}</p>
            <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
            <button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
            <button onClick={() => dispatch({ type: 'set', payload: 10 })}>Set to 10</button>
        </div>
    );
}

// In a real scenario, CounterComponent would be rendered and its state
// would update as buttons are clicked.

Explanation:

The CounterComponent uses useImmerReducer to manage a simple counter state. The counterReducer directly mutates the draft object for 'increment' and 'decrement' actions. For the 'set' action, it also mutates the draft. useImmerReducer handles the immer integration, making these updates clean.

Example 2: Managing Nested State (Todo List)

import React, { Dispatch, Reducer } from 'react';
import { produce, Draft } from 'immer';

// Assume useImmerReducer is implemented elsewhere and imported
// import { useImmerReducer } from './useImmerReducer';

// Define the hook signature for context
declare function useImmerReducer<S, A>(
    reducer: (draft: Draft<S>, action: A) => void | S,
    initialState: S,
    initializer?: (initialState: S) => S
): [S, Dispatch<A>];


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

interface TodoState {
    todos: Todo[];
    nextId: number;
}

type TodoAction =
    | { type: 'addTodo', text: string }
    | { type: 'toggleTodo', id: number }
    | { type: 'removeTodo', id: number };

const todoReducer: Reducer<TodoState, TodoAction> = (draft, action) => {
    switch (action.type) {
        case 'addTodo':
            draft.todos.push({ id: draft.nextId++, text: action.text, completed: false });
            break;
        case 'toggleTodo':
            const todoToToggle = draft.todos.find(todo => todo.id === action.id);
            if (todoToToggle) {
                todoToToggle.completed = !todoToToggle.completed;
            }
            break;
        case 'removeTodo':
            const indexToRemove = draft.todos.findIndex(todo => todo.id === action.id);
            if (indexToRemove !== -1) {
                draft.todos.splice(indexToRemove, 1);
            }
            break;
    }
};

function TodoListComponent() {
    const [state, dispatch] = useImmerReducer(todoReducer, { todos: [], nextId: 1 });

    const addTodo = () => {
        const text = prompt('Enter todo text:');
        if (text) {
            dispatch({ type: 'addTodo', text });
        }
    };

    return (
        <div>
            <h2>Todos</h2>
            <ul>
                {state.todos.map(todo => (
                    <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
                        {todo.text}
                        <button onClick={() => dispatch({ type: 'toggleTodo', id: todo.id })}>Toggle</button>
                        <button onClick={() => dispatch({ type: 'removeTodo', id: todo.id })}>Remove</button>
                    </li>
                ))}
            </ul>
            <button onClick={addTodo}>Add Todo</button>
        </div>
    );
}

// In a real scenario, TodoListComponent would be rendered.
// Adding todos, toggling their completion, and removing them would
// update the UI based on the new state.

Explanation:

This example demonstrates managing an array of objects (todos) within the state. The reducer mutates the todos array by pushing new items, finding and modifying existing ones, and splicing items out. immer handles the immutability of the todos array and its elements seamlessly.

Constraints

  • React Version: This hook is intended for use with React 16.8 or later, as it relies on hook APIs.
  • Immer Dependency: You will need to install and import immer.
  • TypeScript Usage: The solution must be written in TypeScript.
  • No External Libraries (for the hook itself): While you will use immer internally, do not rely on other state management libraries (like Redux, Zustand, etc.) to implement useImmerReducer.
  • Performance: The hook should aim for performance comparable to React's useReducer with immer applied manually.

Notes

  • Think about how immer's produce function works. It takes a base state, a recipe function (your reducer), and returns the next state.
  • The dispatch function returned by your hook will internally call immer.produce and then dispatch the result to React's internal useReducer.
  • Consider how to handle the initial state and the optional initializer function.
  • The type definitions for Draft<S> are provided by immer. Ensure your reducer function's signature correctly uses this.
  • The core of this challenge is understanding how to wrap immer's functionality within a custom React hook that mirrors useReducer.
Loading editor...
typescript