Hone logo
Hone
Problems

Implement a useThunkReducer Hook in React

This challenge requires you to create a custom React hook called useThunkReducer. This hook should provide the functionality of React's built-in useReducer, but with the added capability to dispatch asynchronous actions (thunks) directly. This is a common pattern for managing complex state logic in React applications, especially when dealing with side effects.

Problem Description

You need to implement a custom React hook named useThunkReducer that mimics the behavior of React.useReducer but allows for the dispatching of functions (thunks) in addition to plain action objects.

Key Requirements:

  1. Initialization: The hook should accept a reducer function, an initial state, and an optional initializer function (similar to useReducer).
  2. State and Dispatch: It should return an array containing the current state and a dispatch function, just like useReducer.
  3. Thunk Dispatching: The dispatch function returned by useThunkReducer must be able to handle both:
    • Plain Action Objects: These should be passed directly to the underlying reducer.
    • Thunk Functions: These are functions that receive the dispatch function itself and the current getState function as arguments. Thunks can then dispatch other actions (plain or thunks) or perform other asynchronous operations.
  4. getState Function: Alongside the dispatch function, a getState function should be made available to thunks, allowing them to access the current state.
  5. Type Safety: The implementation should be fully typed using TypeScript, ensuring type safety for states, actions, and thunks.

Expected Behavior:

  • When a plain action object is dispatched, the reducer should be called with the current state and the action object, and the state should be updated accordingly.
  • When a thunk function is dispatched, it should be executed, receiving the dispatch function and getState function as arguments. The thunk can then trigger further state updates by calling dispatch with other actions or thunks.
  • The hook should correctly handle re-renders when state changes occur.

Edge Cases to Consider:

  • Dispatching multiple thunks in succession.
  • Thunks that dispatch other thunks.
  • Handling of the initial state, especially when an initializer function is provided.
  • Ensuring the getState function always returns the most up-to-date state.

Examples

Example 1: Basic Usage with Plain Actions

import React from 'react';
import { useThunkReducer } from './useThunkReducer'; // Assume your hook is in this file

interface CounterState {
  count: number;
}

type CounterAction =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' };

const counterReducer = (state: CounterState, action: CounterAction): CounterState => {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    default:
      return state;
  }
};

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

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>Decrement</button>
    </div>
  );
}

Input to hook: counterReducer, { count: 0 } dispatch calls: dispatch({ type: 'INCREMENT' }) Output (UI): Renders a counter that increments/decrements.

Example 2: Usage with Thunks

import React from 'react';
import { useThunkReducer } from './useThunkReducer';

interface AsyncCounterState {
  count: number;
  isLoading: boolean;
}

type AsyncCounterAction =
  | { type: 'INCREMENT' }
  | { type: 'START_LOADING' }
  | { type: 'STOP_LOADING' };

// Define a Thunk type for better type safety
type AsyncCounterThunk = (
  dispatch: React.Dispatch<AsyncCounterAction | AsyncCounterThunk>,
  getState: () => AsyncCounterState
) => void | Promise<void>;

const asyncCounterReducer = (state: AsyncCounterState, action: AsyncCounterAction): AsyncCounterState => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'START_LOADING':
      return { ...state, isLoading: true };
    case 'STOP_LOADING':
      return { ...state, isLoading: false };
    default:
      return state;
  }
};

// A thunk to increment the counter after a delay
const delayedIncrement: AsyncCounterThunk = async (dispatch, getState) => {
  dispatch({ type: 'START_LOADING' });
  await new Promise(resolve => setTimeout(resolve, 1000));
  // Using getState to check current state before dispatching
  if (getState().count < 5) {
    dispatch({ type: 'INCREMENT' });
  }
  dispatch({ type: 'STOP_LOADING' });
};

function AsyncCounterComponent() {
  const [state, dispatch] = useThunkReducer(asyncCounterReducer, { count: 0, isLoading: false });

  const handleDelayedIncrement = () => {
    // Dispatching the thunk function
    dispatch(delayedIncrement);
  };

  return (
    <div>
      <p>Count: {state.count}</p>
      <p>Loading: {state.isLoading ? 'Yes' : 'No'}</p>
      <button onClick={handleDelayedIncrement} disabled={state.isLoading}>
        Delayed Increment
      </button>
    </div>
  );
}

Input to hook: asyncCounterReducer, { count: 0, isLoading: false } dispatch calls: dispatch(delayedIncrement) Output (UI): When "Delayed Increment" is clicked, the UI shows "Loading: Yes" for 1 second, then increments the count if it's less than 5, and finally shows "Loading: No".

Example 3: Initializer Function

import React from 'react';
import { useThunkReducer } from './useThunkReducer';

interface InitializerState {
  value: string;
}

type InitializerAction = { type: 'SET_VALUE', payload: string };

const initializerReducer = (state: InitializerState, action: InitializerAction): InitializerState => {
  return action.payload ? { value: action.payload } : state;
};

// Custom initializer function that might fetch data or perform calculations
const initializeMyState = (): InitializerState => {
  // In a real app, this could be an API call
  const initialValue = localStorage.getItem('myAppState') || 'default from initializer';
  return { value: initialValue };
};

function InitializerComponent() {
  const [state, dispatch] = useThunkReducer(initializerReducer, { value: '' }, initializeMyState);

  return (
    <div>
      <p>Initialized Value: {state.value}</p>
      <input
        type="text"
        value={state.value}
        onChange={(e) => dispatch({ type: 'SET_VALUE', payload: e.target.value })}
      />
    </div>
  );
}

Input to hook: initializerReducer, { value: '' }, initializeMyState Output (UI): Renders an input field pre-filled with a value from localStorage or a default string.

Constraints

  • The hook must be implemented in TypeScript.
  • The hook should leverage React's useReducer internally or implement similar logic.
  • The dispatch function must handle both action objects and thunk functions.
  • The getState function must be accessible to thunks.
  • Consider potential performance implications for very frequent dispatches or complex thunks.

Notes

  • Think about how to correctly type the dispatch function to accept both action types and thunk types.
  • Consider using generics to make your hook reusable with any state and action types.
  • The getState function is crucial for thunks to be aware of the current application state before making decisions.
  • The use of React.Dispatch will be helpful for typing.
  • You might need to define custom types for your thunks to ensure type safety.
Loading editor...
typescript